Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 70 additions & 28 deletions src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -185,7 +187,7 @@ public DetectionResult Detect(string directoryPath)
internal const string CompileStampFileName = ".aspire-compile-stamp";

/// <summary>
/// Builds the up-to-date check that lets an unchanged AppHost skip <c>javac</c> entirely.
/// Sets the up-to-date check that lets an unchanged AppHost skip <c>javac</c> entirely when supported.
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -205,28 +207,80 @@ public DetectionResult Detect(string directoryPath)
/// solution do not give back the time this saves.
/// </para>
/// </remarks>
/// <param name="commandSpec">The compile command to update.</param>
/// <param name="classOutputDirectory">Directory javac writes classes to, which is where the stamp lives.</param>
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);
}

/// <inheritdoc />
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,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,13 @@ public DetectionResult Detect(string directoryPath)
/// <inheritdoc />
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",
Expand Down Expand Up @@ -293,5 +292,25 @@ public RuntimeSpec GetRuntimeSpec()
[AppHostTsConfigFileName] = s_appHostTsConfigContent
}
};

SetCertificateBundleEnvironmentVariableIfSupported(runtimeSpec, CertificateBundleEnvironmentVariable);

return runtimeSpec;
}

/// <summary>
/// Sets the certificate bundle environment variable when the runtime contract supports it.
/// </summary>
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);
}
}
6 changes: 6 additions & 0 deletions src/Aspire.TypeSystem/Aspire.TypeSystem.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
adamint marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CommandUpToDateCheck>(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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
{
}
}
Loading