From d8b73870444c24a43d9bbf82c5f73a9041e160d6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 19 Aug 2026 14:53:07 -0400 Subject: [PATCH 1/2] Preserve TypeSystem compatibility with older CLIs Fixes: https://github.com/microsoft/aspire/issues/19503 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptLanguageSupport.cs | 23 +++++++++++++++++-- .../Aspire.TypeSystem.csproj | 6 +++++ .../TypeScriptLanguageSupportTests.cs | 16 +++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs index 2a68dcadf72..3caab4113c3 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs @@ -248,14 +248,13 @@ public DetectionResult Detect(string directoryPath) /// public RuntimeSpec GetRuntimeSpec() { - return new RuntimeSpec + var runtimeSpec = new RuntimeSpec { Language = LanguageId, DisplayName = LanguageDisplayName, CodeGenLanguage = CodeGenTarget, DetectionPatterns = s_detectionPatterns, ExtensionLaunchCapability = "node", - CertificateBundleEnvironmentVariable = CertificateBundleEnvironmentVariable, InstallDependencies = new CommandSpec { Command = "npm", @@ -293,5 +292,25 @@ public RuntimeSpec GetRuntimeSpec() [AppHostTsConfigFileName] = s_appHostTsConfigContent } }; + + SetCertificateBundleEnvironmentVariableIfSupported(runtimeSpec, CertificateBundleEnvironmentVariable); + + return runtimeSpec; + } + + /// + /// Sets the certificate bundle environment variable when the runtime contract supports it. + /// + internal static void SetCertificateBundleEnvironmentVariableIfSupported( + object runtimeSpec, + string environmentVariableName) + { + // Aspire.TypeSystem is force-shared from the installed CLI. A newer codegen assembly can + // therefore run against an older RuntimeSpec that has the same assembly identity but does not + // expose this additive property. Probe by name so the new certificate feature is skipped while + // the rest of code generation remains compatible. + runtimeSpec.GetType() + .GetProperty(nameof(RuntimeSpec.CertificateBundleEnvironmentVariable)) + ?.SetValue(runtimeSpec, environmentVariableName); } } diff --git a/src/Aspire.TypeSystem/Aspire.TypeSystem.csproj b/src/Aspire.TypeSystem/Aspire.TypeSystem.csproj index 5d2736e4e07..77f90ffa5d0 100644 --- a/src/Aspire.TypeSystem/Aspire.TypeSystem.csproj +++ b/src/Aspire.TypeSystem/Aspire.TypeSystem.csproj @@ -59,6 +59,12 @@ package baseline validation), which flags any change to the shipped surface, forcing a deliberate decision about whether the change is additive (no bump) or breaking (bump). + An additive member is not automatically safe for SDK-side codegen that can run against an + older CLI bundle with the same frozen identity. Newer codegen must capability-probe the + member before invoking it; otherwise the older force-shared contract binds successfully and + the direct member call fails with MissingMethodException. See + https://github.com/microsoft/aspire/issues/19503. + Only AssemblyVersion is frozen; FileVersion, InformationalVersion, and the NuGet package version remain build-derived so diagnostics keep real build identities. Aspire.TypeSystem is consumed only via ProjectReference (no PackageReference consumers), so the frozen diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs index df33898cf00..619d14973cf 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs @@ -264,6 +264,18 @@ public void GetRuntimeSpec_UsesAppHostSpecificTsConfig() Assert.Contains("npx --no-install tsc --noEmit -p tsconfig.apphost.json && npx --no-install tsx --tsconfig tsconfig.apphost.json \"{appHostFile}\"", watchExecute.Args); } + [Fact] + public void SetCertificateBundleEnvironmentVariableIfSupported_IgnoresLegacyRuntimeSpec() + { + var legacyRuntimeSpec = new LegacyRuntimeSpec(); + + TypeScriptLanguageSupport.SetCertificateBundleEnvironmentVariableIfSupported( + legacyRuntimeSpec, + "NODE_EXTRA_CA_CERTS"); + + Assert.NotNull(legacyRuntimeSpec); + } + [Fact] public void Scaffold_EmitsScaffoldedEslintConfigVerbatim() { @@ -313,4 +325,8 @@ private static void AssertPortInRange(int port, int minInclusive, int maxExclusi Assert.InRange(port, minInclusive, maxExclusive - 1); Assert.True(port < WindowsEphemeralPortMin, $"Expected port {port} to be below the Windows ephemeral range."); } + + private sealed class LegacyRuntimeSpec + { + } } From db60a0a08be26d893ccc64c9478185ba25e935cb Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 19 Aug 2026 19:31:23 -0400 Subject: [PATCH 2/2] Handle Java TypeSystem compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JavaLanguageSupport.cs | 98 +++++++++++++------ .../JavaLanguageSupportTests.cs | 56 +++++++++++ 2 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportTests.cs diff --git a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs index d352dce88e6..2287b2949c1 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs @@ -1,6 +1,8 @@ // 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 Aspire.TypeSystem; namespace Aspire.Hosting.CodeGeneration.Java; @@ -185,7 +187,7 @@ public DetectionResult Detect(string directoryPath) internal const string CompileStampFileName = ".aspire-compile-stamp"; /// - /// Builds the up-to-date check that lets an unchanged AppHost skip javac entirely. + /// Sets the up-to-date check that lets an unchanged AppHost skip javac entirely when supported. /// /// /// @@ -205,28 +207,80 @@ public DetectionResult Detect(string directoryPath) /// solution do not give back the time this saves. /// /// + /// The compile command to update. /// Directory javac writes classes to, which is where the stamp lives. - internal static CommandUpToDateCheck CreateCompileUpToDateCheck(string classOutputDirectory) + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "The installed CLI roots the force-shared contract when this property exists.")] + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The installed CLI roots the force-shared contract when this property exists.")] + internal static void SetCompileUpToDateCheckIfSupported(object commandSpec, string classOutputDirectory) { - return new CommandUpToDateCheck + // Aspire.TypeSystem is force-shared from the installed CLI. A newer codegen assembly can + // therefore run against an older CommandSpec that has the same assembly identity but does not + // expose this additive property. Probe by name so the optimization is skipped and the older CLI + // compiles on every launch rather than failing to load the Java language support. + var upToDateCheckProperty = commandSpec.GetType().GetProperty(nameof(CommandSpec.UpToDateCheck)); + + if (upToDateCheckProperty is null) { - Inputs = - [ - "{appHostFile}", - "./**", - $"{GeneratedSourcesDirectory}/**", - "src/main/java/**" - ], - // Only sources are inputs. Without this the .class files javac writes beside the sources in - // the flat layout would invalidate the very check they were produced under. - FileExtensions = [".java"], - StampFile = Path.Combine(classOutputDirectory, CompileStampFileName) - }; + return; + } + + var expectedTypeName = $"{typeof(CommandSpec).Namespace}.{nameof(CommandUpToDateCheck)}"; + var upToDateCheckType = upToDateCheckProperty.PropertyType; + var inputsProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.Inputs)); + var fileExtensionsProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.FileExtensions)); + var stampFileProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.StampFile)); + + if (upToDateCheckProperty.SetMethod is null || + !upToDateCheckProperty.SetMethod.IsPublic || + upToDateCheckType.Assembly != typeof(CommandSpec).Assembly || + upToDateCheckType.FullName != expectedTypeName || + upToDateCheckType.IsAbstract || + upToDateCheckType.GetConstructor(Type.EmptyTypes) is null || + inputsProperty?.PropertyType != typeof(string[]) || + inputsProperty.SetMethod is null || + !inputsProperty.SetMethod.IsPublic || + fileExtensionsProperty?.PropertyType != typeof(string[]) || + fileExtensionsProperty.SetMethod is null || + !fileExtensionsProperty.SetMethod.IsPublic || + stampFileProperty?.PropertyType != typeof(string) || + stampFileProperty.SetMethod is null || + !stampFileProperty.SetMethod.IsPublic) + { + throw new MissingMemberException( + $"The runtime {nameof(CommandSpec.UpToDateCheck)} contract does not match {expectedTypeName}."); + } + + var upToDateCheck = Activator.CreateInstance(upToDateCheckType) + ?? throw new MissingMemberException($"The runtime type {expectedTypeName} could not be created."); + + inputsProperty.SetValue(upToDateCheck, new[] + { + "{appHostFile}", + "./**", + $"{GeneratedSourcesDirectory}/**", + "src/main/java/**" + }); + // Only sources are inputs. Without this the .class files javac writes beside the sources in + // the flat layout would invalidate the very check they were produced under. + fileExtensionsProperty.SetValue(upToDateCheck, new[] { ".java" }); + stampFileProperty.SetValue(upToDateCheck, Path.Combine(classOutputDirectory, CompileStampFileName)); + upToDateCheckProperty.SetValue(commandSpec, upToDateCheck); } /// public RuntimeSpec GetRuntimeSpec() { + var compile = new CommandSpec + { + // No shell. javac creates the destination directory itself, so there is nothing + // left that needed one, and running without a shell means arguments are not + // re-split: a project under a path such as "C:\My Projects" works unchanged, on + // Windows and Unix alike, from a single spec. + Command = "javac", + Args = [.. s_javacOptions, "-d", BuildOutputDirectory, $"@{GeneratedSourcesListPath}", "{appHostFile}"] + }; + SetCompileUpToDateCheckIfSupported(compile, BuildOutputDirectory); + return new RuntimeSpec { Language = LanguageId, @@ -238,19 +292,7 @@ public RuntimeSpec GetRuntimeSpec() // would otherwise start a shell), and it lets --no-build skip the compile. // A Maven or Gradle AppHost replaces both commands via JavaAppHostToolchainResolver. InstallDependencies = null, - PreExecute = - [ - new CommandSpec - { - // No shell. javac creates the destination directory itself, so there is nothing - // left that needed one, and running without a shell means arguments are not - // re-split: a project under a path such as "C:\My Projects" works unchanged, on - // Windows and Unix alike, from a single spec. - Command = "javac", - Args = [.. s_javacOptions, "-d", BuildOutputDirectory, $"@{GeneratedSourcesListPath}", "{appHostFile}"], - UpToDateCheck = CreateCompileUpToDateCheck(BuildOutputDirectory) - } - ], + PreExecute = [compile], // Debugging the AppHost itself goes through the same Java debug adapter the resources use. // The CLI only takes this path when the extension reports the capability, so a CLI-only // run is unaffected. diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportTests.cs b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportTests.cs new file mode 100644 index 00000000000..5236e2cf7a8 --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportTests.cs @@ -0,0 +1,56 @@ +// 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.Java.Tests; + +public class JavaLanguageSupportTests +{ + [Fact] + public void GetRuntimeSpec_SetsCompileUpToDateCheckWhenSupported() + { + var runtimeSpec = new JavaLanguageSupport().GetRuntimeSpec(); + var compile = Assert.Single(runtimeSpec.PreExecute!); + + AssertCompileUpToDateCheck(compile); + } + + [Fact] + public void SetCompileUpToDateCheckIfSupported_PopulatesCompileUpToDateCheckOnCommandSpec() + { + var compile = new CommandSpec + { + Command = "javac", + Args = ["--release", "25", "-d", ".java-build", "@.aspire/modules/sources.txt", "{appHostFile}"] + }; + + JavaLanguageSupport.SetCompileUpToDateCheckIfSupported(compile, ".java-build"); + + AssertCompileUpToDateCheck(compile); + } + + [Fact] + public void SetCompileUpToDateCheckIfSupported_IgnoresLegacyCommandSpec() + { + var exception = Record.Exception(() => + JavaLanguageSupport.SetCompileUpToDateCheckIfSupported( + new LegacyCommandSpec(), + ".java-build")); + + Assert.Null(exception); + } + + private sealed class LegacyCommandSpec + { + } + + private static void AssertCompileUpToDateCheck(CommandSpec compile) + { + var check = Assert.IsType(compile.UpToDateCheck); + + Assert.Equal(["{appHostFile}", "./**", ".aspire/modules/**", "src/main/java/**"], check.Inputs); + Assert.Equal([".java"], check.FileExtensions!); + Assert.Equal(Path.Combine(".java-build", ".aspire-compile-stamp"), check.StampFile); + } +}