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/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.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);
+ }
+}
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
+ {
+ }
}