diff --git a/src/ModularPipelines.Buildah/Options/BuildahManifestOptions.Generated.cs b/src/ModularPipelines.Buildah/Options/BuildahManifestOptions.Generated.cs index 38d089b2a1b..6211340776f 100644 --- a/src/ModularPipelines.Buildah/Options/BuildahManifestOptions.Generated.cs +++ b/src/ModularPipelines.Buildah/Options/BuildahManifestOptions.Generated.cs @@ -26,4 +26,10 @@ public record BuildahManifestOptions : BuildahOptions [CliFlag("--help", ShortForm = "-h")] public bool? Help { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Buildah/Options/BuildahSourceOptions.Generated.cs b/src/ModularPipelines.Buildah/Options/BuildahSourceOptions.Generated.cs index f433446fd6f..9904cfeb9b7 100644 --- a/src/ModularPipelines.Buildah/Options/BuildahSourceOptions.Generated.cs +++ b/src/ModularPipelines.Buildah/Options/BuildahSourceOptions.Generated.cs @@ -26,4 +26,10 @@ public record BuildahSourceOptions : BuildahOptions [CliFlag("--help", ShortForm = "-h")] public bool? Help { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Docker/Options/DockerComposeBuildOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeBuildOptions.Generated.cs index 4d2aade70d5..28770b259fe 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeBuildOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeBuildOptions.Generated.cs @@ -64,6 +64,12 @@ public record DockerComposeBuildOptions : DockerOptions [CliFlag("--print")] public bool? Print { get; set; } + /// + /// Add a provenance attestation + /// + [CliOption("--provenance", Format = OptionFormat.EqualsSeparated)] + public string? Provenance { get; set; } + /// /// Always attempt to pull a newer version of the image /// @@ -77,11 +83,17 @@ public record DockerComposeBuildOptions : DockerOptions public bool? Push { get; set; } /// - /// Don't print anything to STDOUT + /// Suppress the build output /// [CliFlag("--quiet", ShortForm = "-q")] public bool? Quiet { get; set; } + /// + /// Add a SBOM attestation + /// + [CliOption("--sbom", Format = OptionFormat.EqualsSeparated)] + public string? Sbom { get; set; } + /// /// Set SSH authentications used when building service images. (use 'default' for using your default SSH Agent) /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposeConfigOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeConfigOptions.Generated.cs index 92dff38b2f0..93609f88323 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeConfigOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeConfigOptions.Generated.cs @@ -56,6 +56,12 @@ public record DockerComposeConfigOptions : DockerOptions [CliFlag("--lock-image-digests")] public bool? LockImageDigests { get; set; } + /// + /// Print the model names, one per line. + /// + [CliFlag("--models")] + public bool? Models { get; set; } + /// /// Print the network names, one per line. /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposeEventsOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeEventsOptions.Generated.cs index c632088f67f..636283a7566 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeEventsOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeEventsOptions.Generated.cs @@ -32,6 +32,18 @@ public record DockerComposeEventsOptions : DockerOptions [CliFlag("--json")] public bool? Json { get; set; } + /// + /// Show all events created since timestamp + /// + [CliOption("--since", Format = OptionFormat.EqualsSeparated)] + public string? Since { get; set; } + + /// + /// Stream events until this timestamp + /// + [CliOption("--until", Format = OptionFormat.EqualsSeparated)] + public string? Until { get; set; } + /// /// The SERVICE operand. /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposeExecOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeExecOptions.Generated.cs index 78f65bfb153..3a3c07721bd 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeExecOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeExecOptions.Generated.cs @@ -47,6 +47,12 @@ public record DockerComposeExecOptions( [CliOption("--index", Format = OptionFormat.EqualsSeparated)] public int? Index { get; set; } + /// + /// Disable pseudo-TTY allocation. By default 'docker compose exec' allocates a TTY. (default true) + /// + [CliOption("--no-TTY", ShortForm = "-T", Format = OptionFormat.EqualsSeparated)] + public bool? NoTty { get; set; } + /// /// Give extended privileges to the process /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposePublishOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposePublishOptions.Generated.cs index 8fe684bd950..85274911354 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposePublishOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposePublishOptions.Generated.cs @@ -22,6 +22,12 @@ public record DockerComposePublishOptions( [property: CliArgument(0, Phase = CommandLinePhase.Passthrough, Required = true)] string RepositoryTag ) : DockerOptions { + /// + /// Published compose application (includes referenced images) + /// + [CliFlag("--app")] + public bool? App { get; set; } + /// /// Execute command in dry run mode /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposeStartOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeStartOptions.Generated.cs index e5be419e028..6dc13cf176e 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeStartOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeStartOptions.Generated.cs @@ -26,6 +26,18 @@ public record DockerComposeStartOptions : DockerOptions [CliFlag("--dry-run")] public bool? DryRun { get; set; } + /// + /// Wait for services to be running|healthy. Implies detached mode. + /// + [CliFlag("--wait")] + public bool? Wait { get; set; } + + /// + /// Maximum duration in seconds to wait for the project to be running|healthy + /// + [CliOption("--wait-timeout", Format = OptionFormat.EqualsSeparated)] + public int? WaitTimeout { get; set; } + /// /// The SERVICE operand. /// diff --git a/src/ModularPipelines.Docker/Options/DockerComposeUpOptions.Generated.cs b/src/ModularPipelines.Docker/Options/DockerComposeUpOptions.Generated.cs index da6481c60d0..bafaedf8179 100644 --- a/src/ModularPipelines.Docker/Options/DockerComposeUpOptions.Generated.cs +++ b/src/ModularPipelines.Docker/Options/DockerComposeUpOptions.Generated.cs @@ -134,6 +134,12 @@ public record DockerComposeUpOptions : DockerOptions [CliOption("--pull", Format = OptionFormat.EqualsSeparated)] public string? Pull { get; set; } + /// + /// Suppress the build output + /// + [CliFlag("--quiet-build")] + public bool? QuietBuild { get; set; } + /// /// Pull without printing progress information /// diff --git a/src/ModularPipelines.Flux/Generated/Flux.CommandCoverage.json b/src/ModularPipelines.Flux/Generated/Flux.CommandCoverage.json index a89a01db140..3af1d828b67 100644 --- a/src/ModularPipelines.Flux/Generated/Flux.CommandCoverage.json +++ b/src/ModularPipelines.Flux/Generated/Flux.CommandCoverage.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "toolName": "flux", - "toolVersion": "flux version 2.9.4", + "toolVersion": "flux version 2.9.3", "commandCount": 173, "commandTreeSha256": "cce64ef09e13559016b9deee8b350b34916ce2e46c6e248c25ab24ae62a04c00", "commands": [ diff --git a/src/ModularPipelines.Flux/Options/FluxBootstrapOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxBootstrapOptions.Generated.cs index 49b05b8131d..0f276e3f3d0 100644 --- a/src/ModularPipelines.Flux/Options/FluxBootstrapOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxBootstrapOptions.Generated.cs @@ -368,4 +368,10 @@ public record FluxBootstrapOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxBuildOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxBuildOptions.Generated.cs index 7d35a3da902..d84274dbe60 100644 --- a/src/ModularPipelines.Flux/Options/FluxBuildOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxBuildOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxBuildOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxCreateImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxCreateImageOptions.Generated.cs index f7244886299..5e5055b3039 100644 --- a/src/ModularPipelines.Flux/Options/FluxCreateImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxCreateImageOptions.Generated.cs @@ -183,4 +183,10 @@ public record FluxCreateImageOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxCreateOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxCreateOptions.Generated.cs index c6dd4d1873d..2c11bb5b413 100644 --- a/src/ModularPipelines.Flux/Options/FluxCreateOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxCreateOptions.Generated.cs @@ -183,4 +183,10 @@ public record FluxCreateOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxCreateSecretOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxCreateSecretOptions.Generated.cs index 74c3ced94cc..1e2a16c1e57 100644 --- a/src/ModularPipelines.Flux/Options/FluxCreateSecretOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxCreateSecretOptions.Generated.cs @@ -183,4 +183,10 @@ public record FluxCreateSecretOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxCreateSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxCreateSourceOptions.Generated.cs index 4fc82b2d76b..c6d35a75ea2 100644 --- a/src/ModularPipelines.Flux/Options/FluxCreateSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxCreateSourceOptions.Generated.cs @@ -189,4 +189,10 @@ public record FluxCreateSourceOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxDebugOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxDebugOptions.Generated.cs index c7b8b26cc8d..118b8abfa19 100644 --- a/src/ModularPipelines.Flux/Options/FluxDebugOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxDebugOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxDebugOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxDeleteImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxDeleteImageOptions.Generated.cs index e46a536518a..f27159598ec 100644 --- a/src/ModularPipelines.Flux/Options/FluxDeleteImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxDeleteImageOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxDeleteImageOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxDeleteOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxDeleteOptions.Generated.cs index 6c8a732a9db..98ec8fc365a 100644 --- a/src/ModularPipelines.Flux/Options/FluxDeleteOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxDeleteOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxDeleteOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxDeleteSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxDeleteSourceOptions.Generated.cs index eae44beb014..b800fda1fb4 100644 --- a/src/ModularPipelines.Flux/Options/FluxDeleteSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxDeleteSourceOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxDeleteSourceOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxDiffOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxDiffOptions.Generated.cs index ca97ebe35e2..68ca2dd1c0b 100644 --- a/src/ModularPipelines.Flux/Options/FluxDiffOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxDiffOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxDiffOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxExportArtifactOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxExportArtifactOptions.Generated.cs index 083b93322c1..43c6720a1b2 100644 --- a/src/ModularPipelines.Flux/Options/FluxExportArtifactOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxExportArtifactOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxExportArtifactOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxExportImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxExportImageOptions.Generated.cs index b772f04f5ac..748c05cb58e 100644 --- a/src/ModularPipelines.Flux/Options/FluxExportImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxExportImageOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxExportImageOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxExportOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxExportOptions.Generated.cs index 60c21870932..76e03f9cfd5 100644 --- a/src/ModularPipelines.Flux/Options/FluxExportOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxExportOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxExportOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxExportSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxExportSourceOptions.Generated.cs index 9579817b7c0..d2cfa585df6 100644 --- a/src/ModularPipelines.Flux/Options/FluxExportSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxExportSourceOptions.Generated.cs @@ -177,4 +177,10 @@ public record FluxExportSourceOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxGetArtifactsOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxGetArtifactsOptions.Generated.cs index ab4cab7923f..6c8091219fe 100644 --- a/src/ModularPipelines.Flux/Options/FluxGetArtifactsOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxGetArtifactsOptions.Generated.cs @@ -195,4 +195,10 @@ public record FluxGetArtifactsOptions : FluxOptions [CliFlag("--watch", ShortForm = "-w")] public bool? Watch { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxGetImagesOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxGetImagesOptions.Generated.cs index ab8c4233d1b..dc8dbbb26b2 100644 --- a/src/ModularPipelines.Flux/Options/FluxGetImagesOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxGetImagesOptions.Generated.cs @@ -195,4 +195,10 @@ public record FluxGetImagesOptions : FluxOptions [CliFlag("--watch", ShortForm = "-w")] public bool? Watch { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxGetOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxGetOptions.Generated.cs index f749ddef1d0..ba7df69416c 100644 --- a/src/ModularPipelines.Flux/Options/FluxGetOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxGetOptions.Generated.cs @@ -195,4 +195,10 @@ public record FluxGetOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxGetSourcesOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxGetSourcesOptions.Generated.cs index 8ff46362cac..471c593c0f1 100644 --- a/src/ModularPipelines.Flux/Options/FluxGetSourcesOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxGetSourcesOptions.Generated.cs @@ -195,4 +195,10 @@ public record FluxGetSourcesOptions : FluxOptions [CliFlag("--watch", ShortForm = "-w")] public bool? Watch { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxListOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxListOptions.Generated.cs index 5cf7e15052b..b69c06fc6e3 100644 --- a/src/ModularPipelines.Flux/Options/FluxListOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxListOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxListOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxPluginOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxPluginOptions.Generated.cs index bb53c85a3e5..eacfb1fd1c2 100644 --- a/src/ModularPipelines.Flux/Options/FluxPluginOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxPluginOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxPluginOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxPullOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxPullOptions.Generated.cs index 01fa4888c18..49cf7ffbcbd 100644 --- a/src/ModularPipelines.Flux/Options/FluxPullOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxPullOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxPullOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxPushOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxPushOptions.Generated.cs index 90bc0a8a594..8667d658752 100644 --- a/src/ModularPipelines.Flux/Options/FluxPushOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxPushOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxPushOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxReconcileImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxReconcileImageOptions.Generated.cs index 9d0cec81069..ffb6ef2f883 100644 --- a/src/ModularPipelines.Flux/Options/FluxReconcileImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxReconcileImageOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxReconcileImageOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxReconcileOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxReconcileOptions.Generated.cs index e9e281cf255..741d462711f 100644 --- a/src/ModularPipelines.Flux/Options/FluxReconcileOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxReconcileOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxReconcileOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxReconcileSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxReconcileSourceOptions.Generated.cs index 9f98df24a3e..c28052de772 100644 --- a/src/ModularPipelines.Flux/Options/FluxReconcileSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxReconcileSourceOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxReconcileSourceOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxResumeImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxResumeImageOptions.Generated.cs index 9f27b436ed9..70cbc2a06f0 100644 --- a/src/ModularPipelines.Flux/Options/FluxResumeImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxResumeImageOptions.Generated.cs @@ -177,4 +177,10 @@ public record FluxResumeImageOptions : FluxOptions [CliFlag("--wait")] public bool? Wait { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxResumeOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxResumeOptions.Generated.cs index 5ba419ea60d..ab3a8066faa 100644 --- a/src/ModularPipelines.Flux/Options/FluxResumeOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxResumeOptions.Generated.cs @@ -177,4 +177,10 @@ public record FluxResumeOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxResumeSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxResumeSourceOptions.Generated.cs index ba72e15b78e..d7c7b91d2b9 100644 --- a/src/ModularPipelines.Flux/Options/FluxResumeSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxResumeSourceOptions.Generated.cs @@ -177,4 +177,10 @@ public record FluxResumeSourceOptions : FluxOptions [CliFlag("--wait")] public bool? Wait { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxSuspendImageOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxSuspendImageOptions.Generated.cs index 0f5cc0de93b..6990d81b5a5 100644 --- a/src/ModularPipelines.Flux/Options/FluxSuspendImageOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxSuspendImageOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxSuspendImageOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxSuspendOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxSuspendOptions.Generated.cs index b9bf75963c9..80bc09d9539 100644 --- a/src/ModularPipelines.Flux/Options/FluxSuspendOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxSuspendOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxSuspendOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxSuspendSourceOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxSuspendSourceOptions.Generated.cs index ff13cf5cd26..8edf4122cf0 100644 --- a/src/ModularPipelines.Flux/Options/FluxSuspendSourceOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxSuspendSourceOptions.Generated.cs @@ -171,4 +171,10 @@ public record FluxSuspendSourceOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxTagOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxTagOptions.Generated.cs index c6c448eb761..332fffb8164 100644 --- a/src/ModularPipelines.Flux/Options/FluxTagOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxTagOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxTagOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxTreeArtifactOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxTreeArtifactOptions.Generated.cs index 9385b791ea5..cfe8126ea8a 100644 --- a/src/ModularPipelines.Flux/Options/FluxTreeArtifactOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxTreeArtifactOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxTreeArtifactOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxTreeOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxTreeOptions.Generated.cs index 5056b9ce8c9..62f5ab9eb9d 100644 --- a/src/ModularPipelines.Flux/Options/FluxTreeOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxTreeOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxTreeOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Flux/Options/FluxTriggerOptions.Generated.cs b/src/ModularPipelines.Flux/Options/FluxTriggerOptions.Generated.cs index da562a3621c..e3a39132a8e 100644 --- a/src/ModularPipelines.Flux/Options/FluxTriggerOptions.Generated.cs +++ b/src/ModularPipelines.Flux/Options/FluxTriggerOptions.Generated.cs @@ -165,4 +165,10 @@ public record FluxTriggerOptions : FluxOptions [CliFlag("--verbose")] public bool? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Grype/Options/GrypeDbOptions.Generated.cs b/src/ModularPipelines.Grype/Options/GrypeDbOptions.Generated.cs index 263f4ac7667..aa715a4faef 100644 --- a/src/ModularPipelines.Grype/Options/GrypeDbOptions.Generated.cs +++ b/src/ModularPipelines.Grype/Options/GrypeDbOptions.Generated.cs @@ -50,4 +50,10 @@ public record GrypeDbOptions : GrypeOptions [CliOption("--verbose", ShortForm = "-v", Format = OptionFormat.EqualsSeparated)] public int? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Grype/Options/GrypeDbSearchOptions.Generated.cs b/src/ModularPipelines.Grype/Options/GrypeDbSearchOptions.Generated.cs index 74e0d628627..e0a0a430762 100644 --- a/src/ModularPipelines.Grype/Options/GrypeDbSearchOptions.Generated.cs +++ b/src/ModularPipelines.Grype/Options/GrypeDbSearchOptions.Generated.cs @@ -116,4 +116,10 @@ public record GrypeDbSearchOptions : GrypeOptions [CliOption("--verbose", ShortForm = "-v", Format = OptionFormat.EqualsSeparated)] public int? Verbose { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Kubernetes/Extensions/KustomizeExtensions.Generated.cs b/src/ModularPipelines.Kubernetes/Extensions/KustomizeExtensions.Generated.cs index d16fb481081..953fa1e9824 100644 --- a/src/ModularPipelines.Kubernetes/Extensions/KustomizeExtensions.Generated.cs +++ b/src/ModularPipelines.Kubernetes/Extensions/KustomizeExtensions.Generated.cs @@ -36,7 +36,7 @@ public static IServiceCollection RegisterKustomizeContext(this IServiceCollectio } /// - /// Gets the kustomize service from the pipeline context for compatibility. + /// Gets the kustomize service from the pipeline context. /// /// The pipeline context. /// The service for executing kustomize commands. diff --git a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCatOptions.Generated.cs b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCatOptions.Generated.cs index e1c34d5b5b7..41d90800cf0 100644 --- a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCatOptions.Generated.cs +++ b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCatOptions.Generated.cs @@ -22,6 +22,13 @@ public record KustomizeCfgCatOptions( [property: CliArgument(0, Phase = CommandLinePhase.EarlyOperand, Required = true)] string Dir ) : KustomizeOptions { + /// + /// Creates compatibility options without the newly required directory operand. + /// + public KustomizeCfgCatOptions() : this(string.Empty) + { + } + /// /// annotate resources with their file origins. /// diff --git a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCountOptions.Generated.cs b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCountOptions.Generated.cs index 1e45b60b304..0fa7899360d 100644 --- a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCountOptions.Generated.cs +++ b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgCountOptions.Generated.cs @@ -44,9 +44,6 @@ public record KustomizeCfgCountOptions : KustomizeOptions [CliFlag("--stack-trace")] public bool? StackTrace { get; set; } - /// - /// The DIR operand. - /// [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] public string? Dir { get; set; } diff --git a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgTreeOptions.Generated.cs b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgTreeOptions.Generated.cs index a7abe41e911..e76b51595ba 100644 --- a/src/ModularPipelines.Kubernetes/Options/KustomizeCfgTreeOptions.Generated.cs +++ b/src/ModularPipelines.Kubernetes/Options/KustomizeCfgTreeOptions.Generated.cs @@ -110,9 +110,6 @@ public record KustomizeCfgTreeOptions : KustomizeOptions [CliFlag("--stack-trace")] public bool? StackTrace { get; set; } - /// - /// The DIR operand. - /// [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] public string? Dir { get; set; } diff --git a/src/ModularPipelines.Kubernetes/Options/KustomizeFnRunOptions.Generated.cs b/src/ModularPipelines.Kubernetes/Options/KustomizeFnRunOptions.Generated.cs index 543a39f2902..800a6418555 100644 --- a/src/ModularPipelines.Kubernetes/Options/KustomizeFnRunOptions.Generated.cs +++ b/src/ModularPipelines.Kubernetes/Options/KustomizeFnRunOptions.Generated.cs @@ -110,9 +110,6 @@ public record KustomizeFnRunOptions : KustomizeOptions [CliFlag("--stack-trace")] public bool? StackTrace { get; set; } - /// - /// The DIR operand. - /// [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] public string? Dir { get; set; } diff --git a/src/ModularPipelines.Pulumi/Generated/Pulumi.CommandCoverage.json b/src/ModularPipelines.Pulumi/Generated/Pulumi.CommandCoverage.json index ff837da322a..f0561157791 100644 --- a/src/ModularPipelines.Pulumi/Generated/Pulumi.CommandCoverage.json +++ b/src/ModularPipelines.Pulumi/Generated/Pulumi.CommandCoverage.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "toolName": "pulumi", - "toolVersion": "v3.256.0", + "toolVersion": "v3.255.0", "commandCount": 226, "commandTreeSha256": "c23fbb83a37eb69c9cf574d6e85ffad67be77a27cab19c1f4a05c22b31237a9e", "commands": [ diff --git a/src/ModularPipelines.Pulumi/Options/PulumiNewOptions.Generated.cs b/src/ModularPipelines.Pulumi/Options/PulumiNewOptions.Generated.cs index 2f363f2dbd2..317a25ab769 100644 --- a/src/ModularPipelines.Pulumi/Options/PulumiNewOptions.Generated.cs +++ b/src/ModularPipelines.Pulumi/Options/PulumiNewOptions.Generated.cs @@ -20,6 +20,12 @@ namespace ModularPipelines.Pulumi.Options; [CliSubCommand("new")] public record PulumiNewOptions : PulumiOptions { + /// + /// Prompt to use for Pulumi AI + /// + [CliOption("--ai", Format = OptionFormat.EqualsSeparated)] + public string? Ai { get; set; } + /// /// Config to save /// @@ -62,6 +68,12 @@ public record PulumiNewOptions : PulumiOptions [CliFlag("--help", ShortForm = "-h")] public bool? Help { get; set; } + /// + /// Language to use for Pulumi AI (must be one of TypeScript, JavaScript, Python, Go, C#, Java, or YAML) + /// + [CliOption("--language", Format = OptionFormat.EqualsSeparated)] + public string? Language { get; set; } + /// /// List locally installed templates and exit /// @@ -98,6 +110,12 @@ public record PulumiNewOptions : PulumiOptions [CliOption("--stack", ShortForm = "-s", Format = OptionFormat.EqualsSeparated)] public string? Stack { get; set; } + /// + /// Run in template mode, which will skip prompting for AI or Template functionality + /// + [CliFlag("--template-mode", ShortForm = "-t")] + public bool? TemplateMode { get; set; } + /// /// Skip prompts and proceed with default values /// diff --git a/src/ModularPipelines.Pulumi/Options/PulumiProjectNewOptions.Generated.cs b/src/ModularPipelines.Pulumi/Options/PulumiProjectNewOptions.Generated.cs index d27450855b1..f38511af557 100644 --- a/src/ModularPipelines.Pulumi/Options/PulumiProjectNewOptions.Generated.cs +++ b/src/ModularPipelines.Pulumi/Options/PulumiProjectNewOptions.Generated.cs @@ -20,6 +20,12 @@ namespace ModularPipelines.Pulumi.Options; [CliSubCommand("project", "new")] public record PulumiProjectNewOptions : PulumiOptions { + /// + /// Prompt to use for Pulumi AI + /// + [CliOption("--ai", Format = OptionFormat.EqualsSeparated)] + public string? Ai { get; set; } + /// /// Config to save /// @@ -62,6 +68,12 @@ public record PulumiProjectNewOptions : PulumiOptions [CliFlag("--help", ShortForm = "-h")] public bool? Help { get; set; } + /// + /// Language to use for Pulumi AI (must be one of TypeScript, JavaScript, Python, Go, C#, Java, or YAML) + /// + [CliOption("--language", Format = OptionFormat.EqualsSeparated)] + public string? Language { get; set; } + /// /// List locally installed templates and exit /// @@ -98,6 +110,12 @@ public record PulumiProjectNewOptions : PulumiOptions [CliOption("--stack", ShortForm = "-s", Format = OptionFormat.EqualsSeparated)] public string? Stack { get; set; } + /// + /// Run in template mode, which will skip prompting for AI or Template functionality + /// + [CliFlag("--template-mode", ShortForm = "-t")] + public bool? TemplateMode { get; set; } + /// /// Skip prompts and proceed with default values /// diff --git a/src/ModularPipelines.Trivy/Options/TrivyModuleOptions.Generated.cs b/src/ModularPipelines.Trivy/Options/TrivyModuleOptions.Generated.cs index f96996dbcbd..9d40aec64e5 100644 --- a/src/ModularPipelines.Trivy/Options/TrivyModuleOptions.Generated.cs +++ b/src/ModularPipelines.Trivy/Options/TrivyModuleOptions.Generated.cs @@ -92,4 +92,10 @@ public record TrivyModuleOptions : TrivyOptions [CliFlag("--version", ShortForm = "-v")] public bool? Version { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Trivy/Options/TrivyPluginOptions.Generated.cs b/src/ModularPipelines.Trivy/Options/TrivyPluginOptions.Generated.cs index d7f2d35cb5a..eb35167b424 100644 --- a/src/ModularPipelines.Trivy/Options/TrivyPluginOptions.Generated.cs +++ b/src/ModularPipelines.Trivy/Options/TrivyPluginOptions.Generated.cs @@ -80,4 +80,10 @@ public record TrivyPluginOptions : TrivyOptions [CliFlag("--version", ShortForm = "-v")] public bool? Version { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Trivy/Options/TrivyRegistryOptions.Generated.cs b/src/ModularPipelines.Trivy/Options/TrivyRegistryOptions.Generated.cs index 952f7f1457b..c4a65cadfee 100644 --- a/src/ModularPipelines.Trivy/Options/TrivyRegistryOptions.Generated.cs +++ b/src/ModularPipelines.Trivy/Options/TrivyRegistryOptions.Generated.cs @@ -80,4 +80,10 @@ public record TrivyRegistryOptions : TrivyOptions [CliFlag("--version", ShortForm = "-v")] public bool? Version { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Trivy/Options/TrivyVexOptions.Generated.cs b/src/ModularPipelines.Trivy/Options/TrivyVexOptions.Generated.cs index 878e31680e0..2017d492db6 100644 --- a/src/ModularPipelines.Trivy/Options/TrivyVexOptions.Generated.cs +++ b/src/ModularPipelines.Trivy/Options/TrivyVexOptions.Generated.cs @@ -80,4 +80,10 @@ public record TrivyVexOptions : TrivyOptions [CliFlag("--version", ShortForm = "-v")] public bool? Version { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/src/ModularPipelines.Trivy/Options/TrivyVexRepoOptions.Generated.cs b/src/ModularPipelines.Trivy/Options/TrivyVexRepoOptions.Generated.cs index ef64c50237f..991cacf23aa 100644 --- a/src/ModularPipelines.Trivy/Options/TrivyVexRepoOptions.Generated.cs +++ b/src/ModularPipelines.Trivy/Options/TrivyVexRepoOptions.Generated.cs @@ -80,4 +80,10 @@ public record TrivyVexRepoOptions : TrivyOptions [CliFlag("--version", ShortForm = "-v")] public bool? Version { get; set; } + /// + /// The command operand. + /// + [CliArgument(0, Phase = CommandLinePhase.EarlyOperand)] + public string? Command { get; set; } + } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/CodeGeneratorOrchestratorTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/CodeGeneratorOrchestratorTests.cs index 5ffd1ad1a64..937d9b04a4c 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/CodeGeneratorOrchestratorTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/CodeGeneratorOrchestratorTests.cs @@ -145,6 +145,58 @@ public async Task Cli_Metadata_Is_Preserved_For_Generators() } } + [Test] + public async Task Existing_Generated_Options_Are_Used_As_Api_Baseline() + { + var outputRoot = Path.Combine(Path.GetTempPath(), "mp-orchestrator-tests", Guid.NewGuid().ToString("N")); + var scraper = new FakeCliScraper { Commands = [FakeCommand()] }; + await Orchestrator(scraper, new FakeGenerator()).GenerateAsync("fake", outputRoot); + var optionsDirectory = Path.Combine(outputRoot, ToolOutputDirectory, "Options"); + Directory.CreateDirectory(optionsDirectory); + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "FakeRunOptions.Generated.cs"), + """ + // + // Generated compatibility baseline. + // + + using ModularPipelines.Attributes; + + public record FakeRunOptions + { + [CliFlag("--removed")] + public bool? Removed { get; set; } + } + """); + CliToolDefinition? generatedTool = null; + var generator = new FakeGenerator + { + OnGenerate = tool => + { + generatedTool = tool; + return []; + }, + }; + + try + { + var result = await Orchestrator(scraper, generator).GenerateAsync("fake", outputRoot); + + await Assert.That(result.HasErrors).IsFalse(); + var compatibilityProperty = generatedTool!.Commands.Single().CompatibilityProperties.Single(); + using (Assert.Multiple()) + { + await Assert.That(compatibilityProperty.PropertyName).IsEqualTo("Removed"); + await Assert.That(compatibilityProperty.CSharpType).IsEqualTo("bool?"); + await Assert.That(compatibilityProperty.ForwardToPropertyName).IsNull(); + } + } + finally + { + Directory.Delete(outputRoot, recursive: true); + } + } + [Test] public async Task Catalog_Metadata_Is_Applied_Before_Every_Generator() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/EnumDefinitionStabilizerTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/EnumDefinitionStabilizerTests.cs index 6b4a1f75707..fda406ea9ed 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/EnumDefinitionStabilizerTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/EnumDefinitionStabilizerTests.cs @@ -131,6 +131,47 @@ await Assert.That(stabilized.AllEnums.Single().Values.Single().MemberName) } } + [Test] + public async Task Stabilize_Retains_Removed_Members_And_Restores_Renamed_Members() + { + var outputRoot = Path.Combine(Path.GetTempPath(), "mp-enum-tests", Guid.NewGuid().ToString("N")); + var enumDirectory = Path.Combine(outputRoot, "src", "Fake", "Enums"); + Directory.CreateDirectory(enumDirectory); + File.WriteAllText( + Path.Combine(enumDirectory, "FakeVisibility.Generated.cs"), + "public enum FakeVisibility { " + + "[EnumValue(\"private\")] Private = 4, " + + "[EnumValue(\"public\")] Public = 9 }"); + + try + { + var tool = Tool( + new CliEnumValue { MemberName = "PrivateAccess", CliValue = "private" }, + Value("enterprise")); + + var stabilized = EnumDefinitionStabilizer.Stabilize(tool, outputRoot); + var values = stabilized.AllEnums.Single().Values; + + using (Assert.Multiple()) + { + await Assert.That(values.Select(value => value.MemberName)) + .IsEquivalentTo(["Private", "Public", "Enterprise"]); + await Assert.That(values.Single(value => value.MemberName == "Private").NumericValue) + .IsEqualTo(4); + await Assert.That(values.Single(value => value.MemberName == "Public").CliValue) + .IsEqualTo("public"); + await Assert.That(values.Single(value => value.MemberName == "Public").NumericValue) + .IsEqualTo(9); + await Assert.That(values.Single(value => value.MemberName == "Enterprise").NumericValue) + .IsEqualTo(10); + } + } + finally + { + Directory.Delete(outputRoot, recursive: true); + } + } + [Test] public async Task Stabilize_Rejects_Suspicious_Prose_Values() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GeneratorHardeningTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GeneratorHardeningTests.cs index 2208e477dbd..a343699c46c 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GeneratorHardeningTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GeneratorHardeningTests.cs @@ -100,6 +100,35 @@ private static CliToolDefinition Tool(params CliCommandDefinition[] commands) => Commands = commands, }; + private static GeneratedApiProperty BaselineProperty( + string propertyName, + string cSharpType, + string? switchName = null, + int? argumentPosition = null, + bool isRequired = false, + bool isCompatibility = false, + string? forwardToPropertyName = null, + bool useInitAccessor = false) => + new( + propertyName, + cSharpType, + switchName, + argumentPosition, + isRequired, + isCompatibility, + forwardToPropertyName, + null, + useInitAccessor); + + private static CliOptionDefinition RequiredOption(string switchName, string propertyName) => + new() + { + SwitchName = switchName, + PropertyName = propertyName, + CSharpType = "string", + IsRequired = true, + }; + [Test] public async Task Command_Facade_Generation_Can_Be_Disabled_Independently_Of_Options() { @@ -789,6 +818,1195 @@ public async Task OptionsClassGenerator_Renames_Global_Compatibility_Targets() } } + [Test] + public async Task ApiCompatibilityPreserver_Retains_Removed_Scraped_Options() + { + var command = Command("ToolBuildOptions", "ToolOptions", ["build"]); + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("RemovedFlag", "bool?", switchName: "--removed-flag")]); + + var generated = (await new OptionsClassGenerator().GenerateAsync(Tool(preserved))).Single().Content; + + using (Assert.Multiple()) + { + await Assert.That(generated).Contains("public bool? RemovedFlag { get; set; }"); + await Assert.That(generated).Contains("RemovedFlag is no longer supported"); + await Assert.That(generated).DoesNotContain("CliFlag(\"--removed-flag\")"); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Restores_Required_Positional_Names() + { + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Dep", + CSharpType = "IEnumerable", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty( + "DepVersion", + "IEnumerable", + argumentPosition: 0, + isRequired: true)]); + + var generated = (await new OptionsClassGenerator().GenerateAsync(Tool(preserved))).Single().Content; + + using (Assert.Multiple()) + { + await Assert.That(generated).Contains("IEnumerable DepVersion"); + await Assert.That(generated).Contains("public IEnumerable Dep"); + await Assert.That(generated).Contains("get => DepVersion;"); + await Assert.That(generated).Contains("init => DepVersion = value;"); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Scalar_To_Collection_Changes() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--command-options", + PropertyName = "CommandOptions", + CSharpType = "IEnumerable?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("CommandOptions", "string?", switchName: "--command-options")])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.CommandOptions changed type from string? to IEnumerable?"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Renamed_Scalar_To_Collection_Changes() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--command-options", + PropertyName = "CommandOptionValues", + CSharpType = "IEnumerable?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("CommandOptions", "string?", switchName: "--command-options")])); + + await Assert.That(exception.Message) + .Contains("changed type from string? to IEnumerable? while being renamed"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Reassigned_Property_Names() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--different", + PropertyName = "CommandOptions", + CSharpType = "string?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("CommandOptions", "string?", switchName: "--command-options")])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.CommandOptions changed CLI switch or argument position"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Reused_Compatibility_Property_With_Different_Type() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--restored", + PropertyName = "RemovedFlag", + CSharpType = "string?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("RemovedFlag", "bool?", isCompatibility: true)])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.RemovedFlag changed type from bool? to string?"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Reused_Compatibility_Property_With_Cli_Identity() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--restored", + PropertyName = "RemovedFlag", + CSharpType = "bool?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("RemovedFlag", "bool?", isCompatibility: true)])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.RemovedFlag changed CLI switch or argument position"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Conflicting_Supplied_Compatibility_Property_Type() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + CompatibilityProperties = + [ + new CliCompatibilityProperty + { + PropertyName = "RemovedFlag", + CSharpType = "string?", + ObsoleteMessage = "Still retained.", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("RemovedFlag", "bool?", isCompatibility: true)])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.RemovedFlag compatibility property changed type from bool? to string?"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Conflicting_Supplied_Compatibility_Forwarding() + { + var command = Command("ToolCopyOptions", "ToolOptions", ["copy"]) with + { + CompatibilityProperties = + [ + new CliCompatibilityProperty + { + PropertyName = "OldName", + CSharpType = "string?", + ForwardToPropertyName = "DifferentName", + ObsoleteMessage = "Use DifferentName.", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [ + BaselineProperty( + "OldName", + "string?", + isCompatibility: true, + forwardToPropertyName: "CurrentName"), + ])); + + await Assert.That(exception.Message) + .Contains("ToolCopyOptions.OldName compatibility property changed forwarding target from CurrentName to DifferentName"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Uses_The_Emitted_Optional_Value_Type() + { + var command = Command("ToolRunOptions", "ToolOptions", ["run"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--progress", + PropertyName = "Progress", + CSharpType = "string?", + ValueArity = CliOptionValueArity.Optional, + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("Progress", "string?", switchName: "--progress")])); + + await Assert.That(exception.Message) + .Contains("ToolRunOptions.Progress changed type from string? to CliOptionValue?"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Optional_Member_Becoming_Required() + { + var command = Command("ToolNewOptions", "ToolOptions", ["new"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Name", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("Name", "string", argumentPosition: 0)])); + + await Assert.That(exception.Message) + .Contains("Name changed from optional to required and would remove its public setter"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Restores_Required_Constructor_Order() + { + var command = Command("ToolMoveOptions", "ToolOptions", ["move"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--second", + PropertyName = "Second", + CSharpType = "string", + IsRequired = true, + }, + new CliOptionDefinition + { + SwitchName = "--first", + PropertyName = "First", + CSharpType = "string", + IsRequired = true, + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + command, + [ + BaselineProperty("First", "string", switchName: "--first", isRequired: true), + BaselineProperty("Second", "string", switchName: "--second", isRequired: true), + ]); + var generated = (await new OptionsClassGenerator().GenerateAsync(Tool(preserved))).Single().Content; + + await Assert.That(generated.IndexOf("string First", StringComparison.Ordinal)) + .IsLessThan(generated.IndexOf("string Second", StringComparison.Ordinal)); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Old_Deconstruct_Arity_For_New_Required_Members() + { + var command = Command("ToolMoveOptions", "ToolOptions", ["move"]) with + { + Options = + [ + new CliOptionDefinition + { + SwitchName = "--source", + PropertyName = "Source", + CSharpType = "string", + IsRequired = true, + }, + new CliOptionDefinition + { + SwitchName = "--destination", + PropertyName = "Destination", + CSharpType = "string", + IsRequired = true, + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("Source", "string", switchName: "--source", isRequired: true)])); + + await Assert.That(exception.Message).Contains("newly required member(s) Destination have no baseline value"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Restores_Required_Member_Demoted_To_Optional() + { + var command = Command("ToolDiffOptions", "ToolOptions", ["diff"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Target", + CSharpType = "string?", + PositionIndex = 0, + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("Target", "string", argumentPosition: 0, isRequired: true)]); + var generated = (await new OptionsClassGenerator().GenerateAsync(Tool(preserved))).Single().Content; + + await Assert.That(generated).Contains("string Target"); + await Assert.That(generated).DoesNotContain("public string? Target"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Removed_Positional_Operands() + { + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + Command("ToolInstallOptions", "ToolOptions", ["install"]), + [BaselineProperty("Name", "string?", argumentPosition: 1)])); + + await Assert.That(exception.Message).Contains("Name positional argument was removed"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Removed_Required_Members() + { + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + Command("ToolAddOptions", "ToolOptions", ["add"]), + [BaselineProperty( + "Package", + "string", + argumentPosition: 0, + isRequired: true)])); + + await Assert.That(exception.Message) + .Contains("ToolAddOptions.Package positional argument was removed"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Constructor_Preservation_When_Required_Member_Is_Added() + { + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Package", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve(command, [])); + + await Assert.That(exception.Message).Contains("newly required member(s) Package have no baseline value"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Retains_Previously_Generated_Secondary_Constructors() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var optionsDirectory = Path.Combine(root, "src", "ModularPipelines.Tool", "Options"); + Directory.CreateDirectory(optionsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolAddOptions.Generated.cs"), + "public record ToolAddOptions([property: CliArgument(0)] string Package) " + + "{ public ToolAddOptions() : this(default!) { } }"); + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Package", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve(Tool(command), root); + var generated = (await new OptionsClassGenerator().GenerateAsync(preserved)).Single().Content; + + await Assert.That(generated).Contains("public ToolAddOptions()"); + await Assert.That(generated).Contains(": this(default!)"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Does_Not_Retain_Nullability_Only_Constructor_Duplicates() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var optionsDirectory = Path.Combine(root, "src", "ModularPipelines.Tool", "Options"); + Directory.CreateDirectory(optionsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolAddOptions.Generated.cs"), + "public record ToolAddOptions([property: CliArgument(0)] string Package) " + + "{ public ToolAddOptions(string? LegacyPackage) : this(LegacyPackage!) { } }"); + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Package", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve(Tool(command), root); + var generated = (await new OptionsClassGenerator().GenerateAsync(preserved)).Single().Content; + + await Assert.That(generated).DoesNotContain("public ToolAddOptions(string? LegacyPackage)"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Deconstruct_Preservation_For_New_Required_Members() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var optionsDirectory = Path.Combine(root, "src", "ModularPipelines.Tool", "Options"); + Directory.CreateDirectory(optionsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolMoveOptions.Generated.cs"), + "public record ToolMoveOptions([property: CliOption(\"--source\")] string Source, " + + "[property: CliOption(\"--destination\")] string Destination) " + + "{ public ToolMoveOptions(string Source) : this(Source, default!) { } " + + "public void Deconstruct(out string Source) { Source = this.Source; } }"); + var command = Command("ToolMoveOptions", "ToolOptions", ["move"]) with + { + Options = + [ + RequiredOption("--source", "Source"), + RequiredOption("--destination", "Destination"), + RequiredOption("--force", "Force"), + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve(Tool(command), root)); + + await Assert.That(exception.Message).Contains("newly required member(s) Force have no baseline value"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Retains_Command_Group_Execute_Facades() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolEditAddOptions.Generated.cs"), + "public record ToolEditAddOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "ToolEditAdd.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class ToolEditAdd { public Task ExecuteAsync(ToolEditAddOptions? options = null) => Task.CompletedTask; }"); + var parent = Command( + "ToolEditAddOptions", + "ToolOptions", + ["edit", "add"], + subDomainGroup: "edit"); + var child = Command( + "ToolEditAddSecretOptions", + "ToolOptions", + ["edit", "add", "secret"], + subDomainGroup: "edit"); + + var preserved = GeneratedApiCompatibilityPreserver.Preserve(Tool(parent, child), root); + var generated = (await new SubDomainClassGenerator().GenerateAsync(preserved)) + .Single(file => file.RelativePath.EndsWith( + "ToolEditAdd.Generated.cs", + StringComparison.Ordinal)) + .Content; + + await Assert.That(generated).Contains("Task ExecuteAsync("); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Removed_Command_Facades() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolRemovedOptions.Generated.cs"), + "public record ToolRemovedOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "Tool.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class Tool { public Task RemovedAsync(ToolRemovedOptions? options = null) => Task.CompletedTask; }"); + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + Tool(Command("ToolCurrentOptions", "ToolOptions", ["current"])), + root)); + + await Assert.That(exception.Message) + .Contains("ToolRemovedOptions command disappeared from generated facade"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Optional_Facade_When_Required_Member_Is_Added() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolAddOptions.Generated.cs"), + "public record ToolAddOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "Tool.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class Tool { public Task AddAsync(ToolAddOptions? options = null) => Task.CompletedTask; }"); + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "Package", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve(Tool(command), root)); + + await Assert.That(exception.Message).Contains("newly required member(s) Package have no baseline value"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Retains_Leaf_Facade_When_It_Gains_Children() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolGroupChildOptions.Generated.cs"), + "public record ToolGroupChildOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "ToolGroup.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class ToolGroup { public Task ChildAsync(ToolGroupChildOptions? options = null) => Task.CompletedTask; }"); + var child = Command( + "ToolGroupChildOptions", + "ToolOptions", + ["group", "child"], + subDomainGroup: "group"); + var grandchild = Command( + "ToolGroupChildSubOptions", + "ToolOptions", + ["group", "child", "sub"], + subDomainGroup: "group"); + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + Tool(child, grandchild), + root); + var generated = await new SubDomainClassGenerator().GenerateAsync(preserved); + var groupFacade = generated.Single(file => Path.GetFileName(file.RelativePath) + .Equals("ToolGroup.Generated.cs", StringComparison.Ordinal)); + var childFacade = generated.Single(file => Path.GetFileName(file.RelativePath) + .Equals("ToolGroupChild.Generated.cs", StringComparison.Ordinal)); + + using (Assert.Multiple()) + { + await Assert.That(groupFacade.Content) + .Contains("Task ChildAsync("); + await Assert.That(childFacade.Content) + .Contains("Task ExecuteAsync("); + } + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Ignores_Other_Tool_Facades_In_Shared_Package() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolCurrentOptions.Generated.cs"), + "public record ToolCurrentOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "Tool.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class Tool { public Task CurrentAsync(ToolCurrentOptions? options = null) => Task.CompletedTask; }"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "Other.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class Other { public Task RemovedAsync(OtherRemovedOptions? options = null) => Task.CompletedTask; }"); + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + Tool(Command("ToolCurrentOptions", "ToolOptions", ["current"])), + root); + + await Assert.That(preserved.Commands).HasSingleItem(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Accepts_Current_Command_Group_Alias_Facades() + { + var root = Path.Combine(Path.GetTempPath(), $"service-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Options")); + Directory.CreateDirectory(Path.Combine(packageDirectory, "Services")); + try + { + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Options", "ToolBuilderBakeOptions.Generated.cs"), + "public record ToolBuilderBakeOptions;"); + await File.WriteAllTextAsync( + Path.Combine(packageDirectory, "Services", "ToolBuilder.Generated.cs"), + "namespace ModularPipelines.Tool.Services; " + + "public class ToolBuilder { public Task BakeAsync(ToolBuilderBakeOptions? options = null) => Task.CompletedTask; }"); + var tool = Tool(Command( + "ToolBuildxBakeOptions", + "ToolOptions", + ["buildx", "bake"], + subDomainGroup: "Buildx")) with + { + CommandGroupAliases = + [ + new CliCommandGroupAlias + { + Alias = "builder", + CanonicalCommand = "buildx", + ObsoleteMessage = "Use buildx instead.", + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve(tool, root); + + await Assert.That(preserved.Commands).HasSingleItem(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Alias_Constructors_For_New_Required_Members() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var optionsDirectory = Path.Combine(root, "src", "ModularPipelines.Tool", "Options"); + Directory.CreateDirectory(optionsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuildxBakeOptions.Generated.cs"), + "public record ToolBuildxBakeOptions([property: CliOption(\"--source\")] string Source);"); + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuilderBakeOptions.Generated.cs"), + "public record ToolBuilderBakeOptions : ToolBuildxBakeOptions " + + "{ public ToolBuilderBakeOptions(string Source) : base(Source) { } }"); + var command = Command( + "ToolBuildxBakeOptions", + "ToolOptions", + ["buildx", "bake"], + subDomainGroup: "Buildx") with + { + Options = + [ + RequiredOption("--source", "Source"), + RequiredOption("--destination", "Destination"), + ], + }; + var tool = Tool(command) with + { + CommandGroupAliases = + [ + new CliCommandGroupAlias + { + Alias = "builder", + CanonicalCommand = "buildx", + ObsoleteMessage = "Use buildx instead.", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve(tool, root)); + + await Assert.That(exception.Message).Contains("newly required member(s) Destination have no baseline value"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Parameterless_Alias_Constructor_For_New_Required_Members() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var optionsDirectory = Path.Combine(root, "src", "ModularPipelines.Tool", "Options"); + Directory.CreateDirectory(optionsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuildxBakeOptions.Generated.cs"), + "public record ToolBuildxBakeOptions;"); + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuilderBakeOptions.Generated.cs"), + "public record ToolBuilderBakeOptions : ToolBuildxBakeOptions;"); + var command = Command( + "ToolBuildxBakeOptions", + "ToolOptions", + ["buildx", "bake"], + subDomainGroup: "Buildx") with + { + Options = [RequiredOption("--source", "Source")], + }; + var tool = Tool(command) with + { + CommandGroupAliases = + [ + new CliCommandGroupAlias + { + Alias = "builder", + CanonicalCommand = "buildx", + ObsoleteMessage = "Use buildx instead.", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve(tool, root)); + + await Assert.That(exception.Message).Contains("newly required member(s) Source have no baseline value"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Retains_Command_Group_Alias_Enum_Properties() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + var optionsDirectory = Path.Combine(packageDirectory, "Options"); + var enumsDirectory = Path.Combine(packageDirectory, "Enums"); + Directory.CreateDirectory(optionsDirectory); + Directory.CreateDirectory(enumsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuildxBakeOptions.Generated.cs"), + "public record ToolBuildxBakeOptions { " + + "[CliOption(\"--progress\")] public ToolBuildxBakeProgress? Progress { get; set; } }"); + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolBuilderBakeOptions.Generated.cs"), + "public record ToolBuilderBakeOptions : ToolBuildxBakeOptions { " + + "[CliOption(\"--progress\")] public new ToolBuilderBakeProgress? Progress { get; set; } }"); + await File.WriteAllTextAsync( + Path.Combine(enumsDirectory, "ToolBuildxBakeProgress.Generated.cs"), + "public enum ToolBuildxBakeProgress { [EnumValue(\"plain\")] Plain = 4 }"); + await File.WriteAllTextAsync( + Path.Combine(enumsDirectory, "ToolBuilderBakeProgress.Generated.cs"), + "public enum ToolBuilderBakeProgress { [EnumValue(\"plain\")] Plain = 4 }"); + var enumDefinition = new CliEnumDefinition + { + EnumName = "ToolBuildxBakeProgress", + Values = + [ + new CliEnumValue { MemberName = "Plain", CliValue = "plain" }, + new CliEnumValue { MemberName = "Tty", CliValue = "tty" }, + ], + }; + var command = Command( + "ToolBuildxBakeOptions", + "ToolOptions", + ["buildx", "bake"], + subDomainGroup: "Buildx", + options: + [ + new CliOptionDefinition + { + SwitchName = "--progress", + PropertyName = "Progress", + CSharpType = "ToolBuildxBakeProgress?", + EnumDefinition = enumDefinition, + }, + ]); + var tool = Tool(command) with + { + CommandGroupAliases = + [ + new CliCommandGroupAlias + { + Alias = "builder", + CanonicalCommand = "buildx", + ObsoleteMessage = "Use buildx instead.", + }, + ], + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve(tool, root); + var generatedOptions = await new OptionsClassGenerator().GenerateAsync(preserved); + var generatedAlias = generatedOptions.Single(file => Path.GetFileName(file.RelativePath) + .Equals("ToolBuilderBakeOptions.Generated.cs", StringComparison.Ordinal)).Content; + var generatedCanonical = generatedOptions.Single(file => Path.GetFileName(file.RelativePath) + .Equals("ToolBuildxBakeOptions.Generated.cs", StringComparison.Ordinal)).Content; + var generatedEnums = await new EnumGenerator().GenerateAsync(preserved); + + using (Assert.Multiple()) + { + await Assert.That(generatedCanonical) + .Contains("public ToolBuildxBakeProgress? Progress { get; set; }"); + await Assert.That(generatedAlias) + .Contains("public new ToolBuilderBakeProgress? Progress"); + await Assert.That(generatedAlias) + .Contains("(ToolBuildxBakeProgress)(int)value.Value"); + await Assert.That(generatedEnums.Select(file => Path.GetFileName(file.RelativePath))) + .Contains("ToolBuildxBakeProgress.Generated.cs"); + await Assert.That(generatedEnums.Select(file => Path.GetFileName(file.RelativePath))) + .Contains("ToolBuilderBakeProgress.Generated.cs"); + await Assert.That(generatedEnums.Single(file => Path.GetFileName(file.RelativePath) + .Equals("ToolBuilderBakeProgress.Generated.cs", StringComparison.Ordinal)).Content) + .Contains("Tty"); + } + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Scopes_Enum_Baselines_To_The_Current_Tool() + { + var root = Path.Combine(Path.GetTempPath(), $"options-api-{Guid.NewGuid():N}"); + var packageDirectory = Path.Combine(root, "src", "ModularPipelines.Tool"); + var optionsDirectory = Path.Combine(packageDirectory, "Options"); + var enumsDirectory = Path.Combine(packageDirectory, "Enums"); + Directory.CreateDirectory(optionsDirectory); + Directory.CreateDirectory(enumsDirectory); + try + { + await File.WriteAllTextAsync( + Path.Combine(optionsDirectory, "ToolRunOptions.Generated.cs"), + "public record ToolRunOptions;"); + await File.WriteAllTextAsync( + Path.Combine(enumsDirectory, "ToolLegacy.Generated.cs"), + "public enum ToolLegacy { Value }"); + await File.WriteAllTextAsync( + Path.Combine(enumsDirectory, "OtherLegacy.Generated.cs"), + "public enum OtherLegacy { Value }"); + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + Tool(Command("ToolRunOptions", "ToolOptions", ["run"])), + root); + + await Assert.That(preserved.CompatibilityEnums.Select(static definition => definition.EnumName)) + .IsEquivalentTo(["ToolLegacy"]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task OptionsClassGenerator_Rejects_Alias_Enum_Nullability_Changes() + { + const string aliasClassName = "ToolBuilderBakeOptions"; + var command = Command( + "ToolBuildxBakeOptions", + "ToolOptions", + ["buildx", "bake"], + subDomainGroup: "Buildx") with + { + AliasCompatibilityProperties = new Dictionary> + { + [aliasClassName] = + [ + new CliAliasCompatibilityProperty + { + PropertyName = "Progress", + AliasCSharpType = "ToolBuilderBakeProgress?", + CanonicalCSharpType = "ToolBuildxBakeProgress", + ObsoleteMessage = "Use the canonical property instead.", + }, + ], + }, + }; + var tool = Tool(command) with + { + CommandGroupAliases = + [ + new CliCommandGroupAlias + { + Alias = "builder", + CanonicalCommand = "buildx", + ObsoleteMessage = "Use buildx instead.", + }, + ], + }; + + var exception = Assert.Throws(() => + new OptionsClassGenerator().GenerateAsync(tool)); + + await Assert.That(exception.Message) + .Contains("alias property Progress because its nullability changed"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rekeys_Documentation_Examples_After_Required_Rename() + { + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "CurrentName", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + DocumentationExampleValues = new Dictionary(StringComparer.Ordinal) + { + ["CurrentName"] = "\"example\"", + }, + }; + + var preserved = GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("StableName", "string", argumentPosition: 0, isRequired: true)]); + + await Assert.That(preserved.DocumentationExampleValues.Keys).IsEquivalentTo(["StableName"]); + } + + [Test] + public async Task ApiCompatibilityPreserver_Rejects_Required_Rename_Collisions() + { + var command = Command("ToolAddOptions", "ToolOptions", ["add"]) with + { + PositionalArguments = + [ + new CliPositionalArgument + { + PropertyName = "CurrentName", + CSharpType = "string", + IsRequired = true, + PositionIndex = 0, + }, + ], + Options = + [ + new CliOptionDefinition + { + SwitchName = "--stable-name", + PropertyName = "StableName", + CSharpType = "string?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.Preserve( + command, + [BaselineProperty("StableName", "string", argumentPosition: 0, isRequired: true)])); + + await Assert.That(exception.Message).Contains("would duplicate a member name"); + } + + [Test] + public async Task ApiCompatibilityPreserver_Retains_Removed_Global_Options() + { + var preserved = GeneratedApiCompatibilityPreserver.PreserveGlobalOptions( + Tool(Command("ToolRunOptions", "ToolOptions", ["run"])), + [BaselineProperty("LegacyFlag", "bool?", switchName: "--legacy-flag")]); + + var generated = (await new GlobalOptionsBaseGenerator().GenerateAsync(preserved)).Single().Content; + + using (Assert.Multiple()) + { + await Assert.That(generated).Contains("public virtual bool? LegacyFlag { get; set; }"); + await Assert.That(generated).Contains("LegacyFlag is no longer supported"); + await Assert.That(generated).DoesNotContain("CliFlag(\"--legacy-flag\")"); + } + } + + [Test] + public async Task ApiCompatibilityPreserver_Validates_Supplemental_Global_Options() + { + var tool = Tool(Command("ToolRunOptions", "ToolOptions", ["run"])) with + { + SupplementalGlobalOptions = + [ + new CliOptionDefinition + { + SwitchName = "--progress", + PropertyName = "Progress", + CSharpType = "string?", + }, + ], + }; + + var exception = Assert.Throws(() => + GeneratedApiCompatibilityPreserver.PreserveGlobalOptions( + tool, + [BaselineProperty("Progress", "CliOptionValue?", switchName: "--progress")])); + + await Assert.That(exception.Message) + .Contains("ToolOptions.Progress changed type from CliOptionValue? to string?"); + } + + [Test] + public async Task Global_Compatibility_Targets_Follow_Inherited_Property_Renames() + { + var tool = Tool(Command("ToolRunOptions", "ToolOptions", ["run"])) with + { + GlobalOptions = + [ + new CliOptionDefinition + { + SwitchName = "--arguments", + PropertyName = "Arguments", + CSharpType = "IEnumerable?", + }, + ], + GlobalCompatibilityProperties = + [ + new CliCompatibilityProperty + { + PropertyName = "LegacyArguments", + CSharpType = "IEnumerable?", + ForwardToPropertyName = "Arguments", + ObsoleteMessage = "Use Arguments instead.", + }, + ], + }; + + var resolved = InheritedPropertyCollisionResolver.Resolve(tool); + var generated = (await new GlobalOptionsBaseGenerator().GenerateAsync(resolved)).Single().Content; + + using (Assert.Multiple()) + { + await Assert.That(generated).Contains("public virtual IEnumerable? CliArguments"); + await Assert.That(generated).Contains("get => CliArguments;"); + await Assert.That(generated).Contains("set => CliArguments = value;"); + } + } + [Test] public async Task OptionsClassGenerator_Marks_Secret_Positional_Arguments() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs index 03b964c17a3..b5a3b986449 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs @@ -145,6 +145,6 @@ public IReadOnlyList ApplyFix( IReadOnlyList positionalArguments) => ApplyPositionalArgumentFixes(commandParts, positionalArguments); - public bool IsSecret(string propertyName, bool isFlag) => IsSecretOption(propertyName, isFlag); + public bool IsSecret(string propertyName, bool isFlag) => IsSecretOption(propertyName, isFlag, string.Empty); } } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerCliCompatibilityTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerCliCompatibilityTests.cs index 35882aaa027..421cb8d9331 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerCliCompatibilityTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerCliCompatibilityTests.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.Logging.Abstractions; using ModularPipelines.OptionsGenerator.Models; using ModularPipelines.OptionsGenerator.Scrapers; +using ModularPipelines.OptionsGenerator.Scrapers.Cli; +using ModularPipelines.OptionsGenerator.TypeDetection; namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; @@ -25,6 +28,62 @@ public async Task SupportedAliases_ExcludeAlias_WhenCanonicalCommandWasNotScrape await Assert.That(aliases).IsEmpty(); } + [Test] + public async Task ComposeExec_Preserves_Canonical_NoTty_Switch_Casing() + { + const string helpText = """ + Execute a command in a running container + + Usage: docker compose exec [OPTIONS] SERVICE COMMAND [ARGS...] + + Options: + -T, --no-tty Disable pseudo-TTY allocation + """; + var command = await new TestDockerCliScraper().Parse( + ["docker", "compose", "exec"], + helpText); + + var option = command!.Options.Single(); + await Assert.That(option.SwitchName).IsEqualTo("--no-TTY"); + await Assert.That(option.ShortForm).IsEqualTo("-T"); + } + + [Test] + public async Task Switch_Normalization_Rejects_Distinct_Options_With_One_Canonical_Name() + { + const string helpText = """ + Usage: fake run [OPTIONS] + + Options: + --current string Current value + --legacy string Legacy value + """; + + await Assert.That(() => new CollidingSwitchScraper().Parse( + ["fake", "run"], + helpText)) + .Throws() + .And.HasMessageContaining("maps both '--current' and '--legacy'"); + } + + [Test] + public async Task Switch_Normalization_Treats_Source_Switch_Casing_As_Distinct() + { + const string helpText = """ + Usage: fake run [OPTIONS] + + Options: + --current string Current value + --CURRENT string Upper-case value + """; + + await Assert.That(() => new CollidingSwitchScraper().Parse( + ["fake", "run"], + helpText)) + .Throws() + .And.HasMessageContaining("maps both '--current' and '--CURRENT'"); + } + private static CliCommandDefinition CreateCommand(string commandGroup) { return new CliCommandDefinition @@ -37,4 +96,50 @@ private static CliCommandDefinition CreateCommand(string commandGroup) Options = [], }; } + + private sealed class TestDockerCliScraper : DockerCliScraper + { + public TestDockerCliScraper() + : base( + new ProcessCliCommandExecutor(NullLogger.Instance), + new HelpTextCache(NullLogger.Instance), + NullLogger.Instance) + { + } + + public Task Parse(string[] commandPath, string helpText) + { + var usage = ParseUsageSynopsis(commandPath, helpText); + return ParseCommandAsync(commandPath, helpText, usage, CancellationToken.None); + } + } + + private sealed class CollidingSwitchScraper : CobraCliScraper + { + public CollidingSwitchScraper() + : base( + new ProcessCliCommandExecutor(NullLogger.Instance), + new HelpTextCache(NullLogger.Instance), + NullLogger.Instance) + { + } + + public override string ToolName => "fake"; + + public override string NamespacePrefix => "Fake"; + + public override string TargetNamespace => "ModularPipelines.Fake"; + + public override string OutputDirectory => "src/ModularPipelines.Fake"; + + protected override string NormalizeOptionSwitchName( + string[] commandParts, + string switchName) => "--canonical"; + + public Task Parse(string[] commandPath, string helpText) + { + var usage = ParseUsageSynopsis(commandPath, helpText); + return ParseCommandAsync(commandPath, helpText, usage, CancellationToken.None); + } + } } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/TrivyCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/TrivyCliScraperTests.cs index 9221cc6eae4..0e2699c28cf 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/TrivyCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/TrivyCliScraperTests.cs @@ -80,6 +80,18 @@ await Assert.That(scraper.NormalizeDescription(description)) } } + [Test] + public async Task Home_Directory_Path_Is_Normalized_Across_Platforms() + { + var scraper = new TestTrivyCliScraper(); + + var description = scraper.NormalizeDescription( + @"module directory (default ""C:\Users\runneradmin\.trivy\modules"")"); + + await Assert.That(description) + .IsEqualTo("module directory (default \"/.trivy/modules\")"); + } + [Test] public async Task Image_Help_Parses_Target_Types_And_Secrets() { @@ -117,6 +129,25 @@ await Assert.That(command.Options.Single(x => x.SwitchName == "--timeout").CShar await Assert.That(command.Options.Single(x => x.SwitchName == "--password").IsSecret).IsTrue(); } + [Test] + public async Task Secret_Config_Path_Is_Not_Classified_As_Secret() + { + const string helpText = """ + Scan a container image + + Usage: + trivy image [flags] IMAGE_NAME + + Secret Flags + --secret-config string specify a path to config file for secret scanning + """; + + var command = await new TestTrivyCliScraper().Parse(["trivy", "image"], helpText); + var secretConfig = command!.Options.Single(x => x.SwitchName == "--secret-config"); + + await Assert.That(secretConfig.IsSecret).IsFalse(); + } + [Test] public async Task Plugin_Upgrade_Accepts_Multiple_Optional_Names() { @@ -160,7 +191,7 @@ trivy plugin run [flags] NAME | URL | FILE_PATH await Assert.That(positionals[1].PropertyName).IsEqualTo("PluginArguments"); await Assert.That(positionals[1].CSharpType).IsEqualTo("IEnumerable?"); await Assert.That(positionals[1].IsRequired).IsFalse(); - await Assert.That(positionals[1].PositionIndex).IsEqualTo(1); + await Assert.That(positionals[1].PositionIndex).IsEqualTo(0); await Assert.That(positionals[1].Phase).IsEqualTo(CommandLinePhase.Passthrough); } @@ -185,6 +216,27 @@ trivy vex repo download [REPO_NAMES] [flags] await Assert.That(positional.IsRequired).IsFalse(); } + [Test] + public async Task Vex_Command_Preserves_Optional_Command_Operand() + { + const string helpText = """ + [EXPERIMENTAL] VEX utilities + + Usage: + trivy vex [command] + + Available Commands: + repo Manage VEX repositories + """; + + var command = await new TestTrivyCliScraper().Parse(["trivy", "vex"], helpText); + var positional = command!.PositionalArguments.Single(); + + await Assert.That(positional.PropertyName).IsEqualTo("Command"); + await Assert.That(positional.CSharpType).IsEqualTo("string?"); + await Assert.That(positional.IsRequired).IsFalse(); + } + private sealed class TestTrivyCliScraper : TrivyCliScraper { public TestTrivyCliScraper() diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/CodeGeneratorOrchestrator.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/CodeGeneratorOrchestrator.cs index a360b9c56ba..70cf4a1cb9a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/CodeGeneratorOrchestrator.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/CodeGeneratorOrchestrator.cs @@ -834,13 +834,17 @@ private async Task GenerateForToolAsync( { var globalOptions = tool.GetGlobalOptions(); var normalizedCommands = GeneratorUtils.NormalizeCommandClassNames(tool.Commands); - var toolDefinition = InheritedPropertyCollisionResolver.Resolve( - ExecutablePrerequisiteCatalog.PrepareForGeneration(tool with + var collisionResolvedTool = InheritedPropertyCollisionResolver.Resolve( + tool with { Commands = normalizedCommands, GlobalOptions = globalOptions, SupplementalGlobalOptions = [], - })); + }); + var compatibleTool = GeneratedApiCompatibilityPreserver.Preserve( + collisionResolvedTool, + outputDirectory); + var toolDefinition = ExecutablePrerequisiteCatalog.PrepareForGeneration(compatibleTool); if (enforceOutputContainment) { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/EnumDefinitionStabilizer.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/EnumDefinitionStabilizer.cs index 9fff6663789..987b195c4c6 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/EnumDefinitionStabilizer.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/EnumDefinitionStabilizer.cs @@ -4,7 +4,7 @@ namespace ModularPipelines.OptionsGenerator.Generators; /// -/// Preserves the public numeric contract of generated enums across regeneration. +/// Preserves generated enum members, CLI values, and numeric values across regeneration. /// internal static partial class EnumDefinitionStabilizer { @@ -43,6 +43,9 @@ public static CliToolDefinition Stabilize( { Commands = commands, GlobalOptions = StabilizeOptions(tool.GlobalOptions, stabilizedEnums), + CompatibilityEnums = tool.CompatibilityEnums + .Select(definition => stabilizedEnums[definition.EnumName]) + .ToList(), }; } @@ -61,14 +64,30 @@ private static CliEnumDefinition Stabilize( { ValidateValues(definition); + var existingValues = ReadExistingValues(existingFile, fallbackExistingFile); + var stabilizedValues = PreserveExistingValues(definition, existingValues); + AppendNewValues(definition, existingValues, stabilizedValues); + + ValidateUniqueMemberNames(definition.EnumName, stabilizedValues); + return definition with { Values = stabilizedValues }; + } + + private static IReadOnlyList ReadExistingValues( + string existingFile, + string? fallbackExistingFile) + { var baselineFile = File.Exists(existingFile) ? existingFile : fallbackExistingFile; - var existingValues = baselineFile is not null && File.Exists(baselineFile) + return baselineFile is not null && File.Exists(baselineFile) ? ParseExistingValues(File.ReadAllText(baselineFile)) : []; + } - var existingByCliValue = existingValues.ToDictionary(value => value.CliValue, StringComparer.Ordinal); + private static List PreserveExistingValues( + CliEnumDefinition definition, + IReadOnlyList existingValues) + { var incomingByCliValue = definition.Values.ToDictionary(value => value.CliValue, StringComparer.Ordinal); var stabilizedValues = new List(definition.Values.Count); @@ -81,14 +100,43 @@ private static CliEnumDefinition Stabilize( MemberName = existingValue.MemberName, NumericValue = existingValue.NumericValue, }); + continue; } + + var reusedMember = definition.Values.FirstOrDefault(value => value.MemberName.Equals( + existingValue.MemberName, + StringComparison.Ordinal)); + if (reusedMember is not null) + { + throw new InvalidOperationException( + $"Enum '{definition.EnumName}' member '{existingValue.MemberName}' changed CLI value from " + + $"'{existingValue.CliValue}' to '{reusedMember.CliValue}'."); + } + + stabilizedValues.Add(new CliEnumValue + { + MemberName = existingValue.MemberName, + CliValue = existingValue.CliValue, + NumericValue = existingValue.NumericValue, + }); } + return stabilizedValues; + } + + private static void AppendNewValues( + CliEnumDefinition definition, + IReadOnlyList existingValues, + List stabilizedValues) + { + var existingCliValues = existingValues + .Select(value => value.CliValue) + .ToHashSet(StringComparer.Ordinal); var usedNumericValues = existingValues .Select(value => value.NumericValue) .ToHashSet(); var newValues = definition.Values - .Where(value => !existingByCliValue.ContainsKey(value.CliValue)) + .Where(value => !existingCliValues.Contains(value.CliValue)) .ToList(); var nextNumericValue = newValues.Count > 0 && usedNumericValues.Count > 0 ? checked(usedNumericValues.Max() + 1) @@ -109,9 +157,6 @@ private static CliEnumDefinition Stabilize( nextNumericValue = checked(nextNumericValue + 1); } } - - ValidateUniqueMemberNames(definition.EnumName, stabilizedValues); - return definition with { Values = stabilizedValues }; } private static void ValidateValues(CliEnumDefinition definition) @@ -125,6 +170,15 @@ private static void ValidateValues(CliEnumDefinition definition) $"Enum '{definition.EnumName}' contains duplicate CLI value '{duplicateCliValue.Key}'."); } + var duplicateMember = definition.Values + .GroupBy(value => value.MemberName, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateMember is not null) + { + throw new InvalidOperationException( + $"Enum '{definition.EnumName}' contains duplicate member '{duplicateMember.Key}'."); + } + var suspiciousValue = definition.Values .FirstOrDefault(value => SuspiciousProseValues.Contains(value.CliValue)); if (suspiciousValue is not null) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratedApiCompatibilityPreserver.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratedApiCompatibilityPreserver.cs new file mode 100644 index 00000000000..c000e29a21a --- /dev/null +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratedApiCompatibilityPreserver.cs @@ -0,0 +1,1313 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ModularPipelines.Attributes; +using ModularPipelines.OptionsGenerator.Models; + +namespace ModularPipelines.OptionsGenerator.Generators; + +internal static class GeneratedApiCompatibilityPreserver +{ + private enum RequiredMemberRestoreResult + { + NotFound, + Restored, + Rejected, + } + + public static CliToolDefinition Preserve(CliToolDefinition tool, string outputDirectory) + { + var optionsDirectory = Path.Combine( + outputDirectory, + tool.OutputDirectory, + "Options"); + if (!Directory.Exists(optionsDirectory)) + { + return tool; + } + + var enumBaseline = ReadEnumBaseline(Path.Combine( + outputDirectory, + tool.OutputDirectory, + "Enums")) + .Where(pair => pair.Key.StartsWith(tool.NamespacePrefix, StringComparison.Ordinal)) + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + MergeCurrentAliasEnumValues(tool, enumBaseline); + tool = tool with + { + CompatibilityEnums = tool.CompatibilityEnums + .Concat(enumBaseline.Values) + .DistinctBy(static definition => definition.EnumName) + .ToArray(), + }; + var baseline = ReadBaseline(optionsDirectory); + var compatibleTool = baseline.TryGetValue($"{tool.NamespacePrefix}Options", out var globalBaseline) + ? PreserveGlobalOptions(tool, globalBaseline.Properties) + : tool; + var facadeMethods = ReadFacadeMethods( + Path.Combine(outputDirectory, tool.OutputDirectory, "Services"), + $"{tool.TargetNamespace}.Services", + tool.NamespacePrefix); + var executeFacadeOptionTypes = facadeMethods + .Where(static method => method.MethodName.Equals("ExecuteAsync", StringComparison.Ordinal)) + .Select(static method => method.OptionsType) + .ToHashSet(StringComparer.Ordinal); + var namedFacadeOptionTypes = facadeMethods + .Where(static method => !method.MethodName.Equals("ExecuteAsync", StringComparison.Ordinal)) + .Select(static method => method.OptionsType) + .ToHashSet(StringComparer.Ordinal); + var optionalFacadeOptionTypes = facadeMethods + .Where(static method => method.IsOptionsOptional) + .Select(static method => method.OptionsType) + .ToHashSet(StringComparer.Ordinal); + var preservedTool = compatibleTool with + { + Commands = compatibleTool.Commands + .Select(command => baseline.TryGetValue(command.ClassName, out var commandBaseline) + ? Preserve(command, commandBaseline.Properties, commandBaseline.Constructors) + : command) + .Select(command => PreserveAliasCompatibility( + compatibleTool, + command, + baseline, + enumBaseline)) + .Select(command => executeFacadeOptionTypes.Contains(command.ClassName) + ? command with { PreserveExecuteFacade = true } + : command) + .Select(command => namedFacadeOptionTypes.Contains(command.ClassName) + ? command with { PreserveNamedFacade = true } + : command) + .Select(command => optionalFacadeOptionTypes.Contains(command.ClassName) + ? command with { PreserveOptionalOptionsParameter = true } + : command) + .ToArray(), + }; + RejectRemovedFacadeMethods(preservedTool, facadeMethods); + return preservedTool; + } + + private static CliCommandDefinition PreserveAliasCompatibility( + CliToolDefinition tool, + CliCommandDefinition command, + IReadOnlyDictionary baseline, + IReadOnlyDictionary enumBaseline) + { + if (command.CommandParts.Length == 0) + { + return command; + } + + var constructorsByAlias = command.AliasCompatibilityConstructors + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + var propertiesByAlias = command.AliasCompatibilityProperties + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + foreach (var alias in tool.CommandGroupAliases.Where(alias => + command.CommandParts[0].Equals( + alias.CanonicalCommand, + StringComparison.OrdinalIgnoreCase))) + { + var aliasClassName = GeneratorUtils.GetAliasedClassName(tool, alias, command.ClassName); + if (!baseline.TryGetValue(aliasClassName, out var aliasBaseline)) + { + continue; + } + + PreserveAliasConstructors( + tool, + command, + alias, + aliasClassName, + aliasBaseline, + constructorsByAlias); + PreserveAliasProperties( + tool, + command, + alias, + aliasClassName, + aliasBaseline, + baseline, + enumBaseline, + propertiesByAlias); + } + + return command with + { + AliasCompatibilityConstructors = constructorsByAlias, + AliasCompatibilityProperties = propertiesByAlias, + }; + } + + private static void PreserveAliasConstructors( + CliToolDefinition tool, + CliCommandDefinition command, + CliCommandGroupAlias alias, + string aliasClassName, + GeneratedApiBaseline aliasBaseline, + Dictionary> constructorsByAlias) + { + var compatibilityConstructors = constructorsByAlias + .GetValueOrDefault(aliasClassName, []) + .ToList(); + var currentRequired = GeneratorUtils.GetRequiredConstructorParameters(command) + .Select(parameter => new GeneratedApiProperty( + parameter.PropertyName, + GeneratorUtils.GetAliasedRequiredConstructorParameterType(parameter, tool, alias), + null, + null, + true, + false, + null, + null)) + .ToArray(); + PreserveCompatibilityConstructors( + aliasBaseline.Properties, + aliasBaseline.Constructors, + currentRequired, + compatibilityConstructors); + SetCompatibilityEntries(constructorsByAlias, aliasClassName, compatibilityConstructors); + } + + private static void PreserveAliasProperties( + CliToolDefinition tool, + CliCommandDefinition command, + CliCommandGroupAlias alias, + string aliasClassName, + GeneratedApiBaseline aliasBaseline, + IReadOnlyDictionary baseline, + IReadOnlyDictionary enumBaseline, + Dictionary> propertiesByAlias) + { + var currentAliasProperties = command.Options + .Where(static option => option.EnumDefinition is not null + && option.ValueArity != CliOptionValueArity.Optional) + .Select(option => ( + option.PropertyName, + option.CSharpType.Replace( + option.EnumDefinition!.EnumName, + GeneratorUtils.GetAliasedClassName( + tool, + alias, + option.EnumDefinition.EnumName), + StringComparison.Ordinal))) + .ToHashSet(); + var compatibilityProperties = propertiesByAlias + .GetValueOrDefault(aliasClassName, []) + .ToList(); + var canonicalProperties = baseline.GetValueOrDefault(command.ClassName)?.Properties ?? []; + + foreach (var baselineProperty in aliasBaseline.Properties) + { + var compatibilityProperty = CreateAliasCompatibilityProperty( + baselineProperty, + canonicalProperties, + enumBaseline, + currentAliasProperties, + compatibilityProperties); + if (compatibilityProperty is not null) + { + compatibilityProperties.Add(compatibilityProperty); + } + } + + SetCompatibilityEntries(propertiesByAlias, aliasClassName, compatibilityProperties); + } + + private static CliAliasCompatibilityProperty? CreateAliasCompatibilityProperty( + GeneratedApiProperty baselineProperty, + IReadOnlyList canonicalProperties, + IReadOnlyDictionary enumBaseline, + IReadOnlySet<(string PropertyName, string CSharpType)> currentAliasProperties, + IReadOnlyCollection compatibilityProperties) + { + var aliasEnumName = GeneratorUtils.GetEnumTypeName(baselineProperty.CSharpType); + if (!enumBaseline.ContainsKey(aliasEnumName) + || currentAliasProperties.Contains((baselineProperty.PropertyName, baselineProperty.CSharpType)) + || compatibilityProperties.Any(existing => existing.PropertyName.Equals( + baselineProperty.PropertyName, + StringComparison.Ordinal))) + { + return null; + } + + var canonicalProperty = canonicalProperties.FirstOrDefault(property => + property.PropertyName.Equals(baselineProperty.PropertyName, StringComparison.Ordinal)); + if (canonicalProperty is null + || !enumBaseline.ContainsKey(GeneratorUtils.GetEnumTypeName(canonicalProperty.CSharpType))) + { + return null; + } + + return new CliAliasCompatibilityProperty + { + PropertyName = baselineProperty.PropertyName, + AliasCSharpType = baselineProperty.CSharpType, + CanonicalCSharpType = canonicalProperty.CSharpType, + ObsoleteMessage = baselineProperty.ObsoleteMessage + ?? $"{baselineProperty.PropertyName} is retained for compatibility.", + }; + } + + private static void SetCompatibilityEntries( + Dictionary> entriesByAlias, + string aliasClassName, + IReadOnlyList entries) + { + if (entries.Count == 0) + { + entriesByAlias.Remove(aliasClassName); + return; + } + + entriesByAlias[aliasClassName] = entries; + } + + internal static CliToolDefinition PreserveGlobalOptions( + CliToolDefinition tool, + IReadOnlyList baselineProperties) + { + var globalClassName = $"{tool.NamespacePrefix}Options"; + var preserved = Preserve( + new CliCommandDefinition + { + FullCommand = tool.ToolName, + CommandParts = [], + ClassName = globalClassName, + ParentClassName = "CommandLineToolOptions", + ToolNamespacePrefix = tool.NamespacePrefix, + Options = tool.GetGlobalOptions(), + CompatibilityProperties = tool.GlobalCompatibilityProperties, + }, + baselineProperties); + + return tool with + { + GlobalOptions = preserved.Options, + SupplementalGlobalOptions = [], + GlobalCompatibilityProperties = preserved.CompatibilityProperties, + }; + } + + internal static CliCommandDefinition Preserve( + CliCommandDefinition command, + IReadOnlyList baselineProperties) => + Preserve(command, baselineProperties, []); + + private static CliCommandDefinition Preserve( + CliCommandDefinition command, + IReadOnlyList baselineProperties, + IReadOnlyList baselineConstructors) + { + var compatibilityProperties = command.CompatibilityProperties.ToList(); + var compatibilityConstructors = command.CompatibilityConstructors.ToList(); + var positionalArguments = command.PositionalArguments.ToArray(); + var options = command.Options.ToArray(); + var violations = new List(); + var renamedProperties = new Dictionary(StringComparer.Ordinal); + + RestoreRequiredMemberNames( + baselineProperties, + positionalArguments, + options, + compatibilityProperties, + renamedProperties, + violations); + RestoreRequiredMemberOrder(baselineProperties, positionalArguments, options); + + var currentProperties = GetCurrentProperties(positionalArguments, options); + foreach (var baseline in baselineProperties) + { + PreserveBaselineProperty( + command, + baseline, + currentProperties, + compatibilityProperties, + violations); + } + + if (violations.Count > 0) + { + throw new InvalidOperationException( + $"Generated API compatibility validation failed for {command.FullCommand}:" + + Environment.NewLine + + string.Join(Environment.NewLine, violations.Select(violation => $"- {violation}"))); + } + + PreserveCompatibilityConstructors( + baselineProperties, + baselineConstructors, + positionalArguments, + options, + compatibilityConstructors); + + return command with + { + Options = options, + PositionalArguments = positionalArguments, + CompatibilityProperties = compatibilityProperties, + CompatibilityConstructors = compatibilityConstructors, + DocumentationExampleValues = RenameDocumentationExampleValues( + command.DocumentationExampleValues, + renamedProperties), + }; + } + + private static void PreserveBaselineProperty( + CliCommandDefinition command, + GeneratedApiProperty baseline, + IReadOnlyList currentProperties, + ICollection compatibilityProperties, + List violations) + { + var sameName = currentProperties.FirstOrDefault(property => + property.PropertyName.Equals(baseline.PropertyName, StringComparison.Ordinal)); + if (sameName is not null) + { + ValidateMatchingProperty(command, baseline, sameName, violations); + return; + } + + if (baseline.IsCompatibility) + { + PreserveCompatibilityProperty( + command, + baseline, + compatibilityProperties, + violations); + return; + } + + var replacement = currentProperties.FirstOrDefault(property => + HasSameCliIdentity(property, baseline)); + if (TryRecordRemovedPropertyViolation(command, baseline, replacement, violations)) + { + return; + } + + AddCompatibilityProperty( + compatibilityProperties, + new CliCompatibilityProperty + { + PropertyName = baseline.PropertyName, + CSharpType = baseline.CSharpType, + ForwardToPropertyName = replacement?.PropertyName, + ObsoleteMessage = replacement is null + ? $"{baseline.PropertyName} is no longer supported by the installed CLI and has no effect." + : $"Use {replacement.PropertyName} instead.", + }); + } + + private static bool TryRecordRemovedPropertyViolation( + CliCommandDefinition command, + GeneratedApiProperty baseline, + GeneratedApiProperty? replacement, + ICollection violations) + { + if (replacement is not null + && !replacement.CSharpType.Equals(baseline.CSharpType, StringComparison.Ordinal)) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} changed type from " + + $"{baseline.CSharpType} to {replacement.CSharpType} " + + $"while being renamed to {replacement.PropertyName}"); + return true; + } + + if (baseline.ArgumentPosition is not null && replacement is null) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} positional argument was removed"); + return true; + } + + if (baseline.IsRequired && replacement is null) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} was removed from the required constructor"); + return true; + } + + return false; + } + + private static void PreserveCompatibilityProperty( + CliCommandDefinition command, + GeneratedApiProperty baseline, + ICollection compatibilityProperties, + ICollection violations) + { + var supplied = compatibilityProperties.FirstOrDefault(property => + property.PropertyName.Equals(baseline.PropertyName, StringComparison.Ordinal)); + if (supplied is null) + { + AddCompatibilityProperty( + compatibilityProperties, + new CliCompatibilityProperty + { + PropertyName = baseline.PropertyName, + CSharpType = baseline.CSharpType, + ForwardToPropertyName = baseline.ForwardToPropertyName, + UseInitAccessor = baseline.UseInitAccessor, + ObsoleteMessage = baseline.ObsoleteMessage + ?? $"{baseline.PropertyName} is retained for compatibility.", + }); + return; + } + + if (!supplied.CSharpType.Equals(baseline.CSharpType, StringComparison.Ordinal)) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} compatibility property changed type from " + + $"{baseline.CSharpType} to {supplied.CSharpType}"); + } + else if (!string.Equals( + supplied.ForwardToPropertyName, + baseline.ForwardToPropertyName, + StringComparison.Ordinal)) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} compatibility property changed forwarding target from " + + $"{baseline.ForwardToPropertyName ?? ""} to {supplied.ForwardToPropertyName ?? ""}"); + } + else if (supplied.UseInitAccessor != baseline.UseInitAccessor) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} compatibility property changed accessor from " + + $"{(baseline.UseInitAccessor ? "init" : "set")} to {(supplied.UseInitAccessor ? "init" : "set")}"); + } + } + + private static void ValidateMatchingProperty( + CliCommandDefinition command, + GeneratedApiProperty baseline, + GeneratedApiProperty current, + List violations) + { + if (!current.CSharpType.Equals(baseline.CSharpType, StringComparison.Ordinal)) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} changed type from " + + $"{baseline.CSharpType} to {current.CSharpType}"); + } + else if (baseline.IsRequired && !current.IsRequired) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} changed from required to optional"); + } + else if (!baseline.IsRequired && current.IsRequired) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} changed from optional to required " + + "and would remove its public setter"); + } + else if (!HasSameCliIdentity(current, baseline)) + { + violations.Add( + $"{command.ClassName}.{baseline.PropertyName} changed CLI switch or argument position"); + } + } + + private static void RestoreRequiredMemberNames( + IReadOnlyList baselineProperties, + CliPositionalArgument[] positionalArguments, + CliOptionDefinition[] options, + ICollection compatibilityProperties, + IDictionary renamedProperties, + ICollection violations) + { + foreach (var baseline in baselineProperties.Where(property => + property.IsRequired && !property.IsCompatibility)) + { + var propertyNames = positionalArguments.Select(argument => argument.PropertyName) + .Concat(options.Select(option => option.PropertyName)); + var positionalResult = TryRestoreRequiredMember( + baseline, + positionalArguments, + propertyNames, + ToGeneratedProperty, + static argument => argument.CSharpType, + argument => argument with + { + PropertyName = baseline.PropertyName, + CSharpType = baseline.CSharpType, + IsRequired = true, + }, + violations, + out var currentName); + if (positionalResult != RequiredMemberRestoreResult.NotFound) + { + if (positionalResult == RequiredMemberRestoreResult.Restored) + { + RecordRequiredMemberRename( + compatibilityProperties, + renamedProperties, + currentName, + baseline); + } + + continue; + } + + var optionResult = TryRestoreRequiredMember( + baseline, + options, + propertyNames, + ToGeneratedProperty, + static option => option.PropertyType, + option => option with + { + PropertyName = baseline.PropertyName, + CSharpType = baseline.CSharpType, + IsRequired = true, + }, + violations, + out currentName); + if (optionResult == RequiredMemberRestoreResult.Restored) + { + RecordRequiredMemberRename( + compatibilityProperties, + renamedProperties, + currentName, + baseline); + } + } + } + + private static RequiredMemberRestoreResult TryRestoreRequiredMember( + GeneratedApiProperty baseline, + T[] members, + IEnumerable propertyNames, + Func toGeneratedProperty, + Func getEmittedType, + Func restore, + ICollection violations, + out string currentName) + { + var index = Array.FindIndex(members, current => + HasSameCliIdentity(toGeneratedProperty(current), baseline) + && getEmittedType(current).TrimEnd('?').Equals( + baseline.CSharpType.TrimEnd('?'), + StringComparison.Ordinal)); + if (index < 0) + { + currentName = string.Empty; + return RequiredMemberRestoreResult.NotFound; + } + + currentName = toGeneratedProperty(members[index]).PropertyName; + if (!CanRestoreName( + baseline.PropertyName, + currentName, + propertyNames, + violations)) + { + return RequiredMemberRestoreResult.Rejected; + } + + members[index] = restore(members[index]); + return RequiredMemberRestoreResult.Restored; + } + + private static bool CanRestoreName( + string baselineName, + string currentName, + IEnumerable propertyNames, + ICollection violations) + { + if (baselineName.Equals(currentName, StringComparison.Ordinal)) + { + return true; + } + + if (!propertyNames.Any(name => name.Equals(baselineName, StringComparison.Ordinal))) + { + return true; + } + + violations.Add($"restoring required member {currentName} to {baselineName} would duplicate a member name"); + return false; + } + + private static void RecordRequiredMemberRename( + ICollection compatibilityProperties, + IDictionary renamedProperties, + string currentName, + GeneratedApiProperty baseline) + { + if (currentName.Equals(baseline.PropertyName, StringComparison.Ordinal)) + { + return; + } + + renamedProperties[currentName] = baseline.PropertyName; + AddRenamedCurrentProperty( + compatibilityProperties, + currentName, + baseline.CSharpType, + baseline.PropertyName); + } + + private static void AddRenamedCurrentProperty( + ICollection compatibilityProperties, + string propertyName, + string cSharpType, + string forwardToPropertyName) => + AddCompatibilityProperty( + compatibilityProperties, + new CliCompatibilityProperty + { + PropertyName = propertyName, + CSharpType = cSharpType, + ForwardToPropertyName = forwardToPropertyName, + UseInitAccessor = true, + ObsoleteMessage = $"Use {forwardToPropertyName} instead.", + }); + + private static void RestoreRequiredMemberOrder( + IReadOnlyList baselineProperties, + CliPositionalArgument[] positionalArguments, + CliOptionDefinition[] options) + { + var baselineOrder = baselineProperties + .Where(static property => property.IsRequired && !property.IsCompatibility) + .Select((property, index) => (property.PropertyName, index)) + .ToDictionary(pair => pair.PropertyName, pair => pair.index, StringComparer.Ordinal); + RestoreRequiredMemberOrder( + positionalArguments, + static argument => argument.IsRequired, + static argument => argument.PropertyName, + baselineOrder); + RestoreRequiredMemberOrder( + options, + static option => option.IsRequired, + static option => option.PropertyName, + baselineOrder); + } + + private static void RestoreRequiredMemberOrder( + T[] members, + Func isRequired, + Func getPropertyName, + IReadOnlyDictionary baselineOrder) + { + var orderedRequired = members + .Where(isRequired) + .OrderBy(member => baselineOrder.GetValueOrDefault(getPropertyName(member), int.MaxValue)) + .ToArray(); + var requiredIndex = 0; + for (var index = 0; index < members.Length; index++) + { + if (isRequired(members[index])) + { + members[index] = orderedRequired[requiredIndex++]; + } + } + } + + private static void AddCompatibilityProperty( + ICollection compatibilityProperties, + CliCompatibilityProperty property) + { + if (compatibilityProperties.Any(existing => + existing.PropertyName.Equals(property.PropertyName, StringComparison.Ordinal))) + { + return; + } + + compatibilityProperties.Add(property); + } + + private static void PreserveCompatibilityConstructors( + IReadOnlyList baselineProperties, + IReadOnlyList baselineConstructors, + IReadOnlyList positionalArguments, + IReadOnlyList options, + List compatibilityConstructors) => + PreserveCompatibilityConstructors( + baselineProperties, + baselineConstructors, + GetCurrentProperties(positionalArguments, options) + .Where(static property => property.IsRequired) + .ToArray(), + compatibilityConstructors); + + private static void PreserveCompatibilityConstructors( + IReadOnlyList baselineProperties, + IReadOnlyList baselineConstructors, + IReadOnlyList currentRequired, + List compatibilityConstructors) + { + if (currentRequired.Count == 0) + { + compatibilityConstructors.Clear(); + return; + } + + var baselineRequired = baselineProperties + .Where(static property => property.IsRequired && !property.IsCompatibility) + .ToArray(); + var addedRequired = currentRequired + .Where(current => !baselineRequired.Any(baseline => + baseline.PropertyName.Equals(current.PropertyName, StringComparison.Ordinal) + && baseline.CSharpType.Equals(current.CSharpType, StringComparison.Ordinal))) + .Select(static property => property.PropertyName) + .ToArray(); + if (currentRequired.Count > baselineRequired.Length && addedRequired.Length > 0) + { + throw new InvalidOperationException( + "Cannot retain generated constructors because newly required member(s) " + + $"{string.Join(", ", addedRequired)} have no baseline value."); + } + + foreach (var constructor in baselineConstructors) + { + AddCompatibilityConstructor(compatibilityConstructors, constructor, currentRequired); + } + + if (HasSameConstructorContract(baselineRequired, currentRequired)) + { + return; + } + + var baselineParameters = baselineRequired + .Select(property => new CliCompatibilityConstructorParameter( + property.PropertyName, + property.CSharpType)) + .ToArray(); + var primaryArguments = currentRequired + .Select(current => baselineRequired.Any(baseline => + baseline.PropertyName.Equals(current.PropertyName, StringComparison.Ordinal) + && baseline.CSharpType.Equals(current.CSharpType, StringComparison.Ordinal)) + ? current.PropertyName + : "default!") + .ToArray(); + AddCompatibilityConstructor( + compatibilityConstructors, + new CliCompatibilityConstructor + { + Parameters = baselineParameters, + PrimaryConstructorArguments = primaryArguments, + PreserveDeconstruct = baselineParameters.Length > 0, + }, + currentRequired); + } + + private static void AddCompatibilityConstructor( + ICollection constructors, + CliCompatibilityConstructor constructor, + IReadOnlyList currentRequired) + { + if (HasSameConstructorSignature(constructor.Parameters, currentRequired)) + { + return; + } + + var existing = constructors.FirstOrDefault(candidate => HasSameConstructorSignature( + candidate.Parameters, + constructor.Parameters)); + if (existing is not null) + { + if (constructor.PreserveDeconstruct && !existing.PreserveDeconstruct) + { + constructors.Remove(existing); + constructors.Add(existing with { PreserveDeconstruct = true }); + } + + return; + } + + constructors.Add(constructor); + } + + private static bool HasSameConstructorContract( + IReadOnlyList left, + IReadOnlyList right) + where TLeft : notnull + where TRight : notnull + { + if (left.Count != right.Count) + { + return false; + } + + return left.Select(GetConstructorParameterContract) + .SequenceEqual(right.Select(GetConstructorParameterContract)); + } + + private static bool HasSameConstructorSignature( + IReadOnlyList left, + IReadOnlyList right) + where TLeft : notnull + where TRight : notnull + { + if (left.Count != right.Count) + { + return false; + } + + return left.Select(GetConstructorParameterSignatureType) + .SequenceEqual(right.Select(GetConstructorParameterSignatureType), StringComparer.Ordinal); + } + + private static string GetConstructorParameterSignatureType(T parameter) => + GetConstructorParameterType(parameter).TrimEnd('?'); + + private static string GetConstructorParameterType(T parameter) => parameter switch + { + GeneratedApiProperty property => property.CSharpType, + CliCompatibilityConstructorParameter compatibilityParameter => compatibilityParameter.CSharpType, + _ => throw new ArgumentOutOfRangeException(nameof(parameter)), + }; + + private static (string PropertyName, string CSharpType) GetConstructorParameterContract(T parameter) => + parameter switch + { + GeneratedApiProperty property => (property.PropertyName, property.CSharpType), + CliCompatibilityConstructorParameter compatibilityParameter => + (compatibilityParameter.PropertyName, compatibilityParameter.CSharpType), + _ => throw new ArgumentOutOfRangeException(nameof(parameter)), + }; + + private static IReadOnlyDictionary RenameDocumentationExampleValues( + IReadOnlyDictionary values, + Dictionary renamedProperties) + { + if (renamedProperties.Count == 0) + { + return values; + } + + return values.ToDictionary( + pair => renamedProperties.GetValueOrDefault(pair.Key, pair.Key), + pair => pair.Value, + StringComparer.Ordinal); + } + + private static GeneratedApiProperty[] GetCurrentProperties( + IEnumerable positionalArguments, + IEnumerable options) => + options.Select(ToGeneratedProperty) + .Concat(positionalArguments.Select(ToGeneratedProperty)) + .ToArray(); + + private static GeneratedApiProperty ToGeneratedProperty(CliPositionalArgument argument) => + new( + argument.PropertyName, + argument.IsRequired ? argument.CSharpType.TrimEnd('?') : argument.CSharpType, + null, + argument.PositionIndex, + argument.IsRequired, + false, + null, + null); + + private static GeneratedApiProperty ToGeneratedProperty(CliOptionDefinition option) => + new( + option.PropertyName, + option.IsRequired ? option.PropertyType.TrimEnd('?') : option.PropertyType, + option.SwitchName, + null, + option.IsRequired, + false, + null, + null); + + private static bool HasSameCliIdentity( + GeneratedApiProperty left, + GeneratedApiProperty right) + { + if (left.ArgumentPosition is not null || right.ArgumentPosition is not null) + { + return left.ArgumentPosition == right.ArgumentPosition; + } + + if (left.SwitchName is not null || right.SwitchName is not null) + { + return left.SwitchName?.Equals(right.SwitchName, StringComparison.Ordinal) == true; + } + + return true; + } + + private static Dictionary ReadBaseline( + string optionsDirectory) + { + var baseline = new Dictionary(StringComparer.Ordinal); + foreach (var path in Directory.EnumerateFiles( + optionsDirectory, + "*.Generated.cs", + SearchOption.TopDirectoryOnly)) + { + var root = CSharpSyntaxTree.ParseText(File.ReadAllText(path)).GetRoot(); + foreach (var declaration in root.DescendantNodes().OfType()) + { + baseline[declaration.Identifier.ValueText] = new GeneratedApiBaseline( + ReadProperties(declaration), + ReadCompatibilityConstructors(declaration)); + } + } + + return baseline; + } + + private static Dictionary ReadEnumBaseline(string enumsDirectory) + { + var baseline = new Dictionary(StringComparer.Ordinal); + if (!Directory.Exists(enumsDirectory)) + { + return baseline; + } + + foreach (var path in Directory.EnumerateFiles( + enumsDirectory, + "*.Generated.cs", + SearchOption.TopDirectoryOnly)) + { + var root = CSharpSyntaxTree.ParseText(File.ReadAllText(path)).GetRoot(); + foreach (var declaration in root.DescendantNodes().OfType()) + { + var nextNumericValue = 0; + var values = new List(declaration.Members.Count); + foreach (var member in declaration.Members) + { + var attributes = member.AttributeLists.SelectMany(static list => list.Attributes); + var cliValueAttribute = FindAttribute(attributes, "EnumValue") + ?? FindAttribute(attributes, "Description"); + var numericValue = GetEnumNumericValue(member.EqualsValue?.Value) ?? nextNumericValue; + values.Add(new CliEnumValue + { + MemberName = member.Identifier.ValueText, + CliValue = GetStringArgument(cliValueAttribute) ?? member.Identifier.ValueText, + NumericValue = numericValue, + }); + nextNumericValue = checked(numericValue + 1); + } + + baseline[declaration.Identifier.ValueText] = new CliEnumDefinition + { + EnumName = declaration.Identifier.ValueText, + Values = values, + }; + } + } + + return baseline; + } + + private static void MergeCurrentAliasEnumValues( + CliToolDefinition tool, + IDictionary enumBaseline) + { + var currentAliasEnums = tool.CommandGroupAliases + .SelectMany(alias => tool.Commands + .Where(command => command.CommandParts.Length > 0 + && command.CommandParts[0].Equals( + alias.CanonicalCommand, + StringComparison.OrdinalIgnoreCase)) + .SelectMany(command => command.Options + .Where(static option => option.EnumDefinition is not null) + .Select(option => option.EnumDefinition! with + { + EnumName = GeneratorUtils.GetAliasedClassName( + tool, + alias, + option.EnumDefinition!.EnumName), + }))) + .GroupBy(static definition => definition.EnumName, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => group.SelectMany(static definition => definition.Values) + .DistinctBy(static value => value.CliValue, StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + foreach (var pair in enumBaseline.ToArray()) + { + if (!currentAliasEnums.TryGetValue(pair.Key, out var currentValues)) + { + continue; + } + + enumBaseline[pair.Key] = pair.Value with + { + Values = pair.Value.Values + .Concat(currentValues) + .DistinctBy(static value => value.CliValue, StringComparer.Ordinal) + .ToArray(), + }; + } + } + + private static int? GetEnumNumericValue(ExpressionSyntax? expression) => expression switch + { + LiteralExpressionSyntax literal when literal.Token.Value is int value => value, + PrefixUnaryExpressionSyntax prefix when prefix.IsKind(SyntaxKind.UnaryMinusExpression) + && prefix.Operand is LiteralExpressionSyntax literal + && literal.Token.Value is int value => -value, + _ => null, + }; + + private static CliCompatibilityConstructor[] ReadCompatibilityConstructors( + RecordDeclarationSyntax declaration) => + declaration.Members + .OfType() + .Where(constructor => constructor.Modifiers.Any(SyntaxKind.PublicKeyword)) + .Where(constructor => constructor.Initializer?.IsKind( + SyntaxKind.ThisConstructorInitializer) == true) + .Select(constructor => ReadCompatibilityConstructor(declaration, constructor)) + .ToArray(); + + private static CliCompatibilityConstructor ReadCompatibilityConstructor( + RecordDeclarationSyntax declaration, + ConstructorDeclarationSyntax constructor) + { + var parameters = constructor.ParameterList.Parameters + .Select(parameter => new CliCompatibilityConstructorParameter( + parameter.Identifier.ValueText, + parameter.Type?.ToString() ?? string.Empty)) + .ToArray(); + return new CliCompatibilityConstructor + { + Parameters = parameters, + PrimaryConstructorArguments = constructor.Initializer!.ArgumentList.Arguments + .Select(argument => argument.Expression.ToString()) + .ToArray(), + PreserveDeconstruct = HasMatchingDeconstruct(declaration, parameters), + }; + } + + private static bool HasMatchingDeconstruct( + RecordDeclarationSyntax declaration, + IReadOnlyList parameters) => + declaration.Members + .OfType() + .Where(method => method.Identifier.ValueText.Equals("Deconstruct", StringComparison.Ordinal)) + .Where(method => method.Modifiers.Any(SyntaxKind.PublicKeyword)) + .Any(method => method.ParameterList.Parameters.Count == parameters.Count + && method.ParameterList.Parameters + .Zip(parameters) + .All(pair => pair.First.Modifiers.Any(SyntaxKind.OutKeyword) + && pair.First.Identifier.ValueText.Equals( + pair.Second.PropertyName, + StringComparison.Ordinal) + && (pair.First.Type?.ToString() ?? string.Empty).Equals( + pair.Second.CSharpType, + StringComparison.Ordinal))); + + private static void RejectRemovedFacadeMethods( + CliToolDefinition tool, + IReadOnlyList baselineFacadeMethods) + { + var currentFacadeMethods = GenerateFacadeMethods(tool).ToHashSet(); + var removedMethods = baselineFacadeMethods + .Where(method => !currentFacadeMethods.Contains(method)) + .Distinct() + .OrderBy(static method => method.DeclaringType, StringComparer.Ordinal) + .ThenBy(static method => method.MethodName, StringComparer.Ordinal) + .ThenBy(static method => method.OptionsType, StringComparer.Ordinal) + .ToArray(); + if (removedMethods.Length == 0) + { + return; + } + + throw new InvalidOperationException( + $"Generated API compatibility validation failed for {tool.ToolName}:" + + Environment.NewLine + + string.Join( + Environment.NewLine, + removedMethods.Select(method => + $"- {method.DeclaringType}.{method.MethodName}({method.OptionsType}): " + + $"{method.OptionsType} command disappeared from generated facade"))); + } + + private static IReadOnlyList GenerateFacadeMethods(CliToolDefinition tool) + { + var generatedFiles = new List(); + generatedFiles.AddRange( + new ServiceInterfaceGenerator().GenerateAsync(tool).GetAwaiter().GetResult()); + generatedFiles.AddRange( + new ServiceImplementationGenerator().GenerateAsync(tool).GetAwaiter().GetResult()); + generatedFiles.AddRange( + new SubDomainClassGenerator().GenerateAsync(tool).GetAwaiter().GetResult()); + return ReadFacadeMethods( + generatedFiles.Select(static file => file.Content), + $"{tool.TargetNamespace}.Services"); + } + + private static IReadOnlyList ReadFacadeMethods( + string servicesDirectory, + string targetNamespace, + string namespacePrefix) + { + if (!Directory.Exists(servicesDirectory)) + { + return []; + } + + var sources = Directory.EnumerateFiles( + servicesDirectory, + $"{namespacePrefix}*.Generated.cs", + SearchOption.TopDirectoryOnly) + .Select(File.ReadAllText); + return ReadFacadeMethods(sources, targetNamespace); + } + + private static IReadOnlyList ReadFacadeMethods( + IEnumerable sources, + string targetNamespace) + { + var methods = new List(); + foreach (var source in sources) + { + var root = CSharpSyntaxTree.ParseText(source).GetRoot(); + foreach (var method in root.DescendantNodes().OfType() + .Where(method => method.Ancestors() + .OfType() + .FirstOrDefault()?.Name.ToString().Equals( + targetNamespace, + StringComparison.Ordinal) == true) + .Where(method => method.Modifiers.Any(SyntaxKind.PublicKeyword))) + { + var optionsParameter = method.ParameterList.Parameters.FirstOrDefault(); + var optionsType = optionsParameter?.Type?.ToString(); + var declaringType = method.Ancestors() + .OfType() + .FirstOrDefault()?.Identifier.ValueText; + if (!string.IsNullOrWhiteSpace(declaringType) + && !string.IsNullOrWhiteSpace(optionsType) + && optionsType.TrimEnd('?').EndsWith("Options", StringComparison.Ordinal)) + { + methods.Add(new GeneratedFacadeMethod( + declaringType, + method.Identifier.ValueText, + optionsType.TrimEnd('?'), + optionsParameter?.Default is not null)); + } + } + } + + return methods; + } + + private static List ReadProperties( + RecordDeclarationSyntax declaration) + { + var properties = new List(); + if (declaration.ParameterList is not null) + { + properties.AddRange(declaration.ParameterList.Parameters.Select(parameter => + ReadProperty( + parameter.Identifier.ValueText, + parameter.Type?.ToString() ?? string.Empty, + parameter.AttributeLists, + isRequired: true, + accessorList: null))); + } + else + { + var baseConstructor = declaration.Members + .OfType() + .FirstOrDefault(constructor => + constructor.Modifiers.Any(SyntaxKind.PublicKeyword) + && constructor.Initializer?.IsKind(SyntaxKind.BaseConstructorInitializer) == true); + if (baseConstructor is not null) + { + properties.AddRange(baseConstructor.ParameterList.Parameters.Select(parameter => + new GeneratedApiProperty( + parameter.Identifier.ValueText, + parameter.Type?.ToString() ?? string.Empty, + null, + null, + true, + false, + null, + null))); + } + } + + properties.AddRange(declaration.Members + .OfType() + .Where(property => property.Modifiers.Any(SyntaxKind.PublicKeyword)) + .Select(property => ReadProperty( + property.Identifier.ValueText, + property.Type.ToString(), + property.AttributeLists, + isRequired: false, + property.AccessorList))); + return properties; + } + + private static GeneratedApiProperty ReadProperty( + string propertyName, + string cSharpType, + SyntaxList attributeLists, + bool isRequired, + AccessorListSyntax? accessorList) + { + var attributes = attributeLists.SelectMany(list => list.Attributes).ToArray(); + var cliArgument = FindAttribute(attributes, "CliArgument"); + var cliOption = FindAttribute(attributes, "CliOption") + ?? FindAttribute(attributes, "CliFlag"); + var obsolete = FindAttribute(attributes, "Obsolete"); + + return new GeneratedApiProperty( + propertyName, + cSharpType, + GetStringArgument(cliOption), + GetIntegerArgument(cliArgument), + isRequired, + obsolete is not null, + GetForwardTarget(accessorList), + GetStringArgument(obsolete), + accessorList?.Accessors.Any(static accessor => + accessor.IsKind(SyntaxKind.InitAccessorDeclaration)) == true); + } + + private static AttributeSyntax? FindAttribute( + IEnumerable attributes, + string name) => + attributes.FirstOrDefault(attribute => + attribute.Name.ToString().Equals(name, StringComparison.Ordinal) + || attribute.Name.ToString().Equals($"{name}Attribute", StringComparison.Ordinal)); + + private static string? GetStringArgument(AttributeSyntax? attribute) => + attribute?.ArgumentList?.Arguments.FirstOrDefault()?.Expression is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.StringLiteralExpression) + ? literal.Token.ValueText + : null; + + private static int? GetIntegerArgument(AttributeSyntax? attribute) => + attribute?.ArgumentList?.Arguments.FirstOrDefault()?.Expression is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.NumericLiteralExpression) + && literal.Token.Value is int value + ? value + : null; + + private static string? GetForwardTarget(AccessorListSyntax? accessorList) => + accessorList?.Accessors + .FirstOrDefault(accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration))? + .ExpressionBody?.Expression is IdentifierNameSyntax identifier + ? identifier.Identifier.ValueText + : null; +} + +internal sealed record GeneratedApiProperty( + string PropertyName, + string CSharpType, + string? SwitchName, + int? ArgumentPosition, + bool IsRequired, + bool IsCompatibility, + string? ForwardToPropertyName, + string? ObsoleteMessage, + bool UseInitAccessor = false); + +internal sealed record GeneratedApiBaseline( + IReadOnlyList Properties, + IReadOnlyList Constructors); + +internal sealed record GeneratedFacadeMethod( + string DeclaringType, + string MethodName, + string OptionsType, + bool IsOptionsOptional); diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratorUtils.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratorUtils.cs index 4dc47a4c78c..bbce36767c3 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratorUtils.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GeneratorUtils.cs @@ -14,6 +14,15 @@ namespace ModularPipelines.OptionsGenerator.Generators; /// public static partial class GeneratorUtils { + internal static string GetEnumTypeName(string cSharpType) + { + var type = cSharpType.TrimEnd('?'); + const string enumerablePrefix = "IEnumerable<"; + return type.StartsWith(enumerablePrefix, StringComparison.Ordinal) + ? type[enumerablePrefix.Length..^1] + : type; + } + private static readonly string[] KnownRunnerHomeDirectories = [ "/home/runner", @@ -21,6 +30,27 @@ public static partial class GeneratorUtils @"C:\Users\runneradmin", ]; + internal static void GenerateCompatibilityProperty( + StringBuilder sb, + CliCompatibilityProperty property, + string modifiers = "") + { + sb.AppendLine($" [Obsolete({FormatStringLiteral(property.ObsoleteMessage)})]"); + + if (property.ForwardToPropertyName is null) + { + sb.AppendLine($" public {modifiers}{property.CSharpType} {property.PropertyName} {{ get; set; }}"); + return; + } + + sb.AppendLine($" public {modifiers}{property.CSharpType} {property.PropertyName}"); + sb.AppendLine(" {"); + sb.AppendLine($" get => {property.ForwardToPropertyName};"); + var setter = property.UseInitAccessor ? "init" : "set"; + sb.AppendLine($" {setter} => {property.ForwardToPropertyName} = value;"); + sb.AppendLine(" }"); + } + internal readonly record struct RequiredConstructorParameter( string PropertyName, string CSharpType, @@ -609,11 +639,21 @@ internal static IReadOnlyList GetRequiredConstruct /// public static string BuildOptionsParameter(CliCommandDefinition command) { - return HasRequiredParameters(command) + return RequiresOptionsParameter(command) ? $"{command.ClassName} options" : $"{command.ClassName}? options = null"; } + /// + /// Whether callers must supply the generated options object. + /// + public static bool RequiresOptionsParameter(CliCommandDefinition command) + { + ArgumentNullException.ThrowIfNull(command); + + return HasRequiredParameters(command) && !command.PreserveOptionalOptionsParameter; + } + /// /// Generates a command method signature for a service interface. /// @@ -677,7 +717,7 @@ public static void GenerateServiceMethod( ArgumentNullException.ThrowIfNull(command); methodName = EnsureAsyncSuffix(methodName); - var hasRequiredParams = HasRequiredParameters(command); + var requiresOptions = RequiresOptionsParameter(command); // XML documentation if (includeXmlDoc && !string.IsNullOrEmpty(command.Description)) @@ -699,7 +739,7 @@ public static void GenerateServiceMethod( sb.AppendLine($"{indent} CancellationToken cancellationToken = default)"); sb.AppendLine($"{indent}{{"); - if (hasRequiredParams) + if (requiresOptions) { sb.AppendLine($"{indent} return await _command.ExecuteCommandLineToolAsync(options, executionOptions, cancellationToken);"); } @@ -968,7 +1008,8 @@ public static IReadOnlyList GetNonCollidingRootCommands(Cl var rootCommands = tool.Commands .Where(c => c.SubDomainGroup is null) - .Where(c => !subDomainNames.Contains(GetCommandGroupIdentifier(c))) + .Where(c => c.PreserveNamedFacade + || !subDomainNames.Contains(GetCommandGroupIdentifier(c))) .ToList(); // Distinct commands can normalize to the same method name (e.g. "build-server" @@ -1119,6 +1160,22 @@ internal static string GetAliasedClassName( + canonicalClassName[canonicalPrefix.Length..]; } + internal static string GetAliasedRequiredConstructorParameterType( + RequiredConstructorParameter parameter, + CliToolDefinition tool, + CliCommandGroupAlias alias) + { + var type = parameter.CSharpType.TrimEnd('?'); + var canonicalEnumName = parameter.Option?.EnumDefinition?.EnumName; + if (canonicalEnumName is null) + { + return type; + } + + var aliasEnumName = GetAliasedClassName(tool, alias, canonicalEnumName); + return type.Replace(canonicalEnumName, aliasEnumName, StringComparison.Ordinal); + } + internal static string GetCommandGroupIdentifier(CliCommandDefinition command) => command.CommandGroupIdentifierOverride ?? GenerateMethodNameFromCommandParts(command.CommandParts); diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GlobalOptionsBaseGenerator.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GlobalOptionsBaseGenerator.cs index 06c035e9c8f..829c06c3b35 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GlobalOptionsBaseGenerator.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/GlobalOptionsBaseGenerator.cs @@ -53,7 +53,11 @@ private static string GenerateBaseOptionsClass(CliToolDefinition tool) sb.AppendLine("using System.ComponentModel.DataAnnotations;"); } - if (globalOptions.Any(o => o.EnumDefinition is not null)) + if (globalOptions.Any(o => o.EnumDefinition is not null) + || tool.GlobalCompatibilityProperties.Any(property => tool.AllEnums.Any(definition => + definition.EnumName.Equals( + GeneratorUtils.GetEnumTypeName(property.CSharpType), + StringComparison.Ordinal)))) { sb.AppendLine($"using {tool.TargetNamespace}.Enums;"); } @@ -86,6 +90,19 @@ private static string GenerateBaseOptionsClass(CliToolDefinition tool) sb.AppendLine(); } + foreach (var compatibilityProperty in tool.GlobalCompatibilityProperties) + { + var newModifier = InheritedPropertyCollisionResolver.IsInheritedPropertyName( + compatibilityProperty.PropertyName) + ? "new " + : string.Empty; + GeneratorUtils.GenerateCompatibilityProperty( + sb, + compatibilityProperty, + $"{newModifier}virtual "); + sb.AppendLine(); + } + sb.AppendLine("}"); return sb.ToString(); diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/InheritedPropertyCollisionResolver.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/InheritedPropertyCollisionResolver.cs index c9593ce222a..7e9e91de68f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/InheritedPropertyCollisionResolver.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/InheritedPropertyCollisionResolver.cs @@ -21,6 +21,7 @@ public static CliToolDefinition Resolve(CliToolDefinition tool) var globalNames = tool.GlobalOptions .Concat(tool.SupplementalGlobalOptions) .Select(option => option.PropertyName) + .Concat(tool.GlobalCompatibilityProperties.Select(property => property.PropertyName)) .ToHashSet(StringComparer.Ordinal); var globalRenamedProperties = new Dictionary(StringComparer.Ordinal); var globalOptions = ResolveOptions( @@ -36,6 +37,7 @@ public static CliToolDefinition Resolve(CliToolDefinition tool) var resolvedGlobalNames = globalOptions .Concat(supplementalGlobalOptions) .Select(option => option.PropertyName) + .Concat(tool.GlobalCompatibilityProperties.Select(property => property.PropertyName)) .ToHashSet(StringComparer.Ordinal); return tool with @@ -48,6 +50,12 @@ public static CliToolDefinition Resolve(CliToolDefinition tool) .ToArray(), GlobalOptions = globalOptions, SupplementalGlobalOptions = supplementalGlobalOptions, + GlobalCompatibilityProperties = tool.GlobalCompatibilityProperties + .Select(property => property.ForwardToPropertyName is { } target + && globalRenamedProperties.TryGetValue(target, out var renamedTarget) + ? property with { ForwardToPropertyName = renamedTarget } + : property) + .ToArray(), }; } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/OptionsClassGenerator.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/OptionsClassGenerator.cs index 9a7891e54bf..9aa19e53213 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/OptionsClassGenerator.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/OptionsClassGenerator.cs @@ -52,10 +52,18 @@ private static GeneratedFile GenerateCompatibilityOptionsAlias( tool, alias, command.ClassName); + var compatibilityConstructors = command.AliasCompatibilityConstructors + .GetValueOrDefault(aliasClassName, []); + var compatibilityProperties = command.AliasCompatibilityProperties + .GetValueOrDefault(aliasClassName, []); + var compatibilityPropertyNames = compatibilityProperties + .Select(static property => property.PropertyName) + .ToHashSet(StringComparer.Ordinal); var requiredParameters = GeneratorUtils.GetRequiredConstructorParameters(command); var enumOptions = command.Options .Where(option => option.EnumDefinition is not null - && option.ValueArity != CliOptionValueArity.Optional) + && option.ValueArity != CliOptionValueArity.Optional + && !compatibilityPropertyNames.Contains(option.PropertyName)) .ToArray(); var sb = new StringBuilder(); GeneratorUtils.GenerateFileHeaderWithNullable(sb, command.DocumentationUrl); @@ -67,9 +75,13 @@ private static GeneratedFile GenerateCompatibilityOptionsAlias( sb.AppendLine("using ModularPipelines.Models;"); } - if (enumOptions.Length > 0) + if (enumOptions.Length > 0 || compatibilityProperties.Count > 0) { - sb.AppendLine("using ModularPipelines.Attributes;"); + if (enumOptions.Length > 0) + { + sb.AppendLine("using ModularPipelines.Attributes;"); + } + sb.AppendLine($"using {tool.TargetNamespace}.Enums;"); } @@ -78,7 +90,10 @@ private static GeneratedFile GenerateCompatibilityOptionsAlias( sb.AppendLine(); sb.AppendLine(GeneratorUtils.GeneratedCodeAttribute); sb.AppendLine("[ExcludeFromCodeCoverage]"); - if (enumOptions.Length == 0 && requiredParameters.Count == 0) + if (enumOptions.Length == 0 + && requiredParameters.Count == 0 + && compatibilityConstructors.Count == 0 + && compatibilityProperties.Count == 0) { sb.AppendLine($"public record {aliasClassName} : {command.ClassName};"); } @@ -92,11 +107,20 @@ private static GeneratedFile GenerateCompatibilityOptionsAlias( requiredParameters, tool, alias); + GenerateCompatibilityConstructors( + sb, + aliasClassName, + compatibilityConstructors); foreach (var option in enumOptions) { GenerateCompatibilityEnumProperty(sb, option, tool, alias); } + foreach (var property in compatibilityProperties) + { + GenerateAliasCompatibilityProperty(sb, property); + } + sb.AppendLine("}"); } @@ -123,7 +147,7 @@ private static void GenerateCompatibilityConstructor( } var parameterDeclarations = requiredParameters.Select(parameter => - $" {GetCompatibilityParameterType(parameter, tool, alias)} {parameter.PropertyName}"); + $" {GeneratorUtils.GetAliasedRequiredConstructorParameterType(parameter, tool, alias)} {parameter.PropertyName}"); var baseArguments = requiredParameters.Select(parameter => GetCompatibilityBaseArgument(parameter, parameter.PropertyName)); sb.AppendLine($" public {aliasClassName}("); @@ -135,22 +159,6 @@ private static void GenerateCompatibilityConstructor( sb.AppendLine(); } - private static string GetCompatibilityParameterType( - GeneratorUtils.RequiredConstructorParameter parameter, - CliToolDefinition tool, - CliCommandGroupAlias alias) - { - var type = parameter.CSharpType.TrimEnd('?'); - var canonicalEnumName = parameter.Option?.EnumDefinition?.EnumName; - if (canonicalEnumName is null) - { - return type; - } - - var aliasEnumName = GeneratorUtils.GetAliasedClassName(tool, alias, canonicalEnumName); - return type.Replace(canonicalEnumName, aliasEnumName, StringComparison.Ordinal); - } - private static string GetCompatibilityBaseArgument( GeneratorUtils.RequiredConstructorParameter parameter, string parameterName) @@ -237,6 +245,60 @@ private static void GenerateCompatibilityEnumCollectionAccessors( + $"static value => ({canonicalEnumName})(int)value);"); } + private static void GenerateAliasCompatibilityProperty( + StringBuilder sb, + CliAliasCompatibilityProperty property) + { + var aliasEnumName = GeneratorUtils.GetEnumTypeName(property.AliasCSharpType); + var canonicalEnumName = GeneratorUtils.GetEnumTypeName(property.CanonicalCSharpType); + var aliasIsEnumerable = property.AliasCSharpType.StartsWith("IEnumerable<", StringComparison.Ordinal); + var canonicalIsEnumerable = property.CanonicalCSharpType.StartsWith("IEnumerable<", StringComparison.Ordinal); + if (aliasIsEnumerable != canonicalIsEnumerable) + { + throw new InvalidOperationException( + $"Cannot retain alias property {property.PropertyName} because its collection shape changed."); + } + + var aliasIsNullable = property.AliasCSharpType.EndsWith('?'); + var canonicalIsNullable = property.CanonicalCSharpType.EndsWith('?'); + if (aliasIsNullable != canonicalIsNullable) + { + throw new InvalidOperationException( + $"Cannot retain alias property {property.PropertyName} because its nullability changed."); + } + + sb.AppendLine($" [Obsolete({GeneratorUtils.FormatStringLiteral(property.ObsoleteMessage)})]"); + sb.AppendLine($" public new {property.AliasCSharpType} {property.PropertyName}"); + sb.AppendLine(" {"); + if (aliasIsEnumerable) + { + var baseNullableOperator = canonicalIsNullable ? "?" : string.Empty; + var aliasNullableOperator = aliasIsNullable ? "?" : string.Empty; + sb.AppendLine( + $" get => base.{property.PropertyName}{baseNullableOperator}.Select(" + + $"static value => ({aliasEnumName})(int)value);"); + sb.AppendLine( + $" set => base.{property.PropertyName} = value{aliasNullableOperator}.Select(" + + $"static value => ({canonicalEnumName})(int)value);"); + } + else if (aliasIsNullable) + { + sb.AppendLine($" get => base.{property.PropertyName} is null"); + sb.AppendLine(" ? null"); + sb.AppendLine($" : ({aliasEnumName})(int)base.{property.PropertyName}.Value;"); + sb.AppendLine($" set => base.{property.PropertyName} = value is null"); + sb.AppendLine(" ? null"); + sb.AppendLine($" : ({canonicalEnumName})(int)value.Value;"); + } + else + { + sb.AppendLine($" get => ({aliasEnumName})(int)base.{property.PropertyName};"); + sb.AppendLine($" set => base.{property.PropertyName} = ({canonicalEnumName})(int)value;"); + } + + sb.AppendLine(" }"); + } + private static string GenerateOptionsClass(CliCommandDefinition command, CliToolDefinition tool) { var sb = new StringBuilder(); @@ -262,12 +324,48 @@ private static string GenerateOptionsClass(CliCommandDefinition command, CliTool var existingPropertyNames = GenerateClassDeclaration(sb, command, positionalArguments); sb.AppendLine("{"); + GenerateCompatibilityConstructors( + sb, + command.ClassName, + command.CompatibilityConstructors); GenerateProperties(sb, command, positionalArguments, existingPropertyNames); sb.AppendLine("}"); return sb.ToString(); } + private static void GenerateCompatibilityConstructors( + StringBuilder sb, + string className, + IReadOnlyList constructors) + { + foreach (var constructor in constructors) + { + var parameters = constructor.Parameters + .Select(parameter => $"{parameter.CSharpType} {parameter.PropertyName}"); + sb.AppendLine($" public {className}({string.Join(", ", parameters)})"); + sb.AppendLine($" : this({string.Join(", ", constructor.PrimaryConstructorArguments)})"); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + sb.AppendLine(); + + if (constructor.PreserveDeconstruct) + { + var deconstructParameters = constructor.Parameters + .Select(parameter => $"out {parameter.CSharpType} {parameter.PropertyName}"); + sb.AppendLine($" public void Deconstruct({string.Join(", ", deconstructParameters)})"); + sb.AppendLine(" {"); + foreach (var parameter in constructor.Parameters) + { + sb.AppendLine($" {parameter.PropertyName} = this.{parameter.PropertyName};"); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + } + } + } + private static void GenerateUsings(StringBuilder sb, CliCommandDefinition command, CliToolDefinition tool) { sb.AppendLine("using System.CodeDom.Compiler;"); @@ -288,7 +386,11 @@ private static void GenerateUsings(StringBuilder sb, CliCommandDefinition comman } // Include enums namespace if any options use enum types - if (command.Options.Any(o => o.EnumDefinition is not null)) + if (command.Options.Any(o => o.EnumDefinition is not null) + || command.CompatibilityProperties.Any(property => tool.AllEnums.Any(definition => + definition.EnumName.Equals( + GeneratorUtils.GetEnumTypeName(property.CSharpType), + StringComparison.Ordinal)))) { sb.AppendLine($"using {tool.TargetNamespace}.Enums;"); } @@ -350,7 +452,10 @@ private static void GenerateProperties( continue; } - GenerateCompatibilityProperty(sb, compatibilityProperty); + GeneratorUtils.GenerateCompatibilityProperty( + sb, + compatibilityProperty, + GetNewModifier(compatibilityProperty.PropertyName)); sb.AppendLine(); } } @@ -445,23 +550,6 @@ private static void GeneratePositionalArgument(StringBuilder sb, CliPositionalAr sb.AppendLine($" public {positional.CSharpType} {positional.PropertyName} {{ get; set; }}"); } - private static void GenerateCompatibilityProperty(StringBuilder sb, CliCompatibilityProperty property) - { - sb.AppendLine($" [Obsolete({GeneratorUtils.FormatStringLiteral(property.ObsoleteMessage)})]"); - - if (property.ForwardToPropertyName is null) - { - sb.AppendLine($" public {GetNewModifier(property.PropertyName)}{property.CSharpType} {property.PropertyName} {{ get; set; }}"); - return; - } - - sb.AppendLine($" public {GetNewModifier(property.PropertyName)}{property.CSharpType} {property.PropertyName}"); - sb.AppendLine(" {"); - sb.AppendLine($" get => {property.ForwardToPropertyName};"); - sb.AppendLine($" set => {property.ForwardToPropertyName} = value;"); - sb.AppendLine(" }"); - } - private static string GetNewModifier(string propertyName) => InheritedPropertyCollisionResolver.IsInheritedPropertyName(propertyName) ? "new " : ""; diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/SubDomainClassGenerator.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/SubDomainClassGenerator.cs index 22ed87fe3a6..c3035f097b3 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/SubDomainClassGenerator.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Generators/SubDomainClassGenerator.cs @@ -81,6 +81,10 @@ private static void GenerateFilesFromTree( IReadOnlyList commandGroupAliases, CliCommandDefinition? parentCommand = null) { + parentCommand ??= tool.Commands.FirstOrDefault(command => + command.PreserveExecuteFacade + && command.ClassName.Equals($"{node.ClassName}Options", StringComparison.Ordinal)); + // Build map of commands that collide with child property names // These will become ExecuteAsync() methods on the child classes instead var collidingCommands = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -96,7 +100,9 @@ private static void GenerateFilesFromTree( } } - var excludedCommands = collidingCommands.Values.ToHashSet(); + var excludedCommands = collidingCommands.Values + .Where(static command => !command.PreserveNamedFacade) + .ToHashSet(); // Generate the command represented by this node as ExecuteAsync(). Root nodes receive // their top-level command; nested nodes receive a command that collided with the @@ -409,7 +415,7 @@ private static string BuildCompatibilityOptionsParameter( tool, alias, command.ClassName); - return GeneratorUtils.HasRequiredParameters(command) + return GeneratorUtils.RequiresOptionsParameter(command) ? $"{aliasOptionsClassName} options" : $"{aliasOptionsClassName}? options = null"; } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliCommandDefinition.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliCommandDefinition.cs index f40daf3406c..549d236980f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliCommandDefinition.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliCommandDefinition.cs @@ -92,6 +92,38 @@ public record CliCommandDefinition /// public IReadOnlyList CompatibilityProperties { get; init; } = []; + /// + /// Previously generated secondary constructors retained for source compatibility. + /// + public IReadOnlyList CompatibilityConstructors { get; init; } = []; + + /// + /// Secondary constructors retained on command-group alias option records, keyed by alias class name. + /// + public IReadOnlyDictionary> AliasCompatibilityConstructors + { get; init; } = new Dictionary>(StringComparer.Ordinal); + + /// + /// Enum-valued properties retained on command-group alias records, keyed by alias class name. + /// + public IReadOnlyDictionary> AliasCompatibilityProperties + { get; init; } = new Dictionary>(StringComparer.Ordinal); + + /// + /// Whether an existing command-group ExecuteAsync facade must remain generated. + /// + public bool PreserveExecuteFacade { get; init; } + + /// + /// Whether an existing named command facade must remain when the command gains children. + /// + public bool PreserveNamedFacade { get; init; } + + /// + /// Whether an existing facade allowed its options argument to be omitted. + /// + public bool PreserveOptionalOptionsParameter { get; init; } + /// /// Public methods retained as obsolete forwarding aliases for compatibility. /// @@ -180,6 +212,63 @@ public record CliCompatibilityProperty /// public string? ForwardToPropertyName { get; init; } + /// + /// Whether the forwarding accessor must use init because its target is init-only. + /// + public bool UseInitAccessor { get; init; } + + /// + /// Obsolete diagnostic shown to consumers. + /// + public required string ObsoleteMessage { get; init; } +} + +/// +/// Describes a secondary generated constructor retained for source compatibility. +/// +public record CliCompatibilityConstructor +{ + /// + /// Parameters exposed by the retained constructor. + /// + public required IReadOnlyList Parameters { get; init; } + + /// + /// Arguments forwarded to the current primary constructor. + /// + public required IReadOnlyList PrimaryConstructorArguments { get; init; } + + /// + /// Whether to retain the deconstruction shape synthesized by the former primary constructor. + /// + public bool PreserveDeconstruct { get; init; } +} + +/// +/// Describes a parameter on a retained generated constructor. +/// +public readonly record struct CliCompatibilityConstructorParameter(string PropertyName, string CSharpType); + +/// +/// Describes an enum-valued property retained on a command-group alias record. +/// +public record CliAliasCompatibilityProperty +{ + /// + /// CLR property name shared with the canonical options record. + /// + public required string PropertyName { get; init; } + + /// + /// Previously generated alias enum type exposed to consumers. + /// + public required string AliasCSharpType { get; init; } + + /// + /// Canonical enum type used by the base options record. + /// + public required string CanonicalCSharpType { get; init; } + /// /// Obsolete diagnostic shown to consumers. /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliToolDefinition.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliToolDefinition.cs index e8a8b99d5d7..81562609364 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliToolDefinition.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Models/CliToolDefinition.cs @@ -75,6 +75,11 @@ public record CliToolDefinition /// public IReadOnlyList SupplementalGlobalOptions { get; init; } = []; + /// + /// Public global properties retained for source and binary compatibility but excluded from CLI rendering. + /// + public IReadOnlyList GlobalCompatibilityProperties { get; init; } = []; + /// /// Returns the validated, deterministic union of scraped and supplemental global options. /// @@ -124,9 +129,15 @@ public IReadOnlyList GetGlobalOptions() => .Concat(GetGlobalOptions() .Where(option => option.EnumDefinition is not null) .Select(option => option.EnumDefinition!)) + .Concat(CompatibilityEnums) .DistinctBy(e => e.EnumName) .ToList(); + /// + /// Generated enum types retained after the installed CLI stops advertising them. + /// + public IReadOnlyList CompatibilityEnums { get; init; } = []; + /// /// Any scraping errors encountered. /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs index 2c7e915db75..65e5301f1bf 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs @@ -264,7 +264,7 @@ protected override IEnumerable ExtractSubcommands(string helpText) private List ParseOptions(string helpText, string[] commandParts) { var options = new List(); - var seenOptions = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenOptions = new Dictionary(StringComparer.OrdinalIgnoreCase); var className = GenerateClassName([ToolName, .. commandParts]); // Find Flags, Options, and Global Flags sections @@ -312,13 +312,25 @@ private List ParseOptions(string helpText, string[] command continue; } + var scrapedLongForm = longForm; + longForm = NormalizeOptionSwitchName(commandParts, longForm); + // Skip duplicates - if (seenOptions.Contains(longForm)) + if (seenOptions.TryGetValue(longForm, out var existingScrapedLongForm)) { + if (!existingScrapedLongForm.Equals( + scrapedLongForm, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Option switch normalization for '{string.Join(' ', commandParts)}' " + + $"maps both '{existingScrapedLongForm}' and '{scrapedLongForm}' to '{longForm}'."); + } + continue; } - seenOptions.Add(longForm); + seenOptions.Add(longForm, scrapedLongForm); var propertyName = NormalizePropertyName(longForm); if (propertyName is null) @@ -374,7 +386,7 @@ private List ParseOptions(string helpText, string[] command ? CliOptionValueArity.Optional : CliOptionValueArity.Required, EnumDefinition = enumDef, - IsSecret = !isBoolean && IsSecretOption(propertyName, isFlag) + IsSecret = !isBoolean && IsSecretOption(propertyName, isFlag, description) }); } } @@ -428,14 +440,24 @@ private static string NormalizeTypeHint(string typeHint, bool hasDefaultValue) /// /// Determines whether a tool option value must be masked in command logs. /// - protected virtual bool IsSecretOption(string propertyName, bool isFlag) => - GeneratorUtils.IsSecretOption(propertyName, isFlag); + protected virtual bool IsSecretOption( + string propertyName, + bool isFlag, + string description) => + GeneratorUtils.IsSecretOption(propertyName, isFlag, description); /// /// Determines whether an option's documented values form a closed set. /// protected virtual bool ShouldGenerateEnum(string[] commandParts, string switchName) => true; + /// + /// Applies tool-specific corrections to an option's canonical switch spelling. + /// + protected virtual string NormalizeOptionSwitchName( + string[] commandParts, + string switchName) => switchName; + /// /// Applies tool-specific corrections when CLI help omits or misreports an option type. /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CosignCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CosignCliScraper.cs index 5f0be1f967b..f3aaaf8116c 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CosignCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CosignCliScraper.cs @@ -85,8 +85,11 @@ protected override IReadOnlyList ApplyPositionalArgumentF }; } - protected override bool IsSecretOption(string propertyName, bool isFlag) => - base.IsSecretOption(propertyName, isFlag) || + protected override bool IsSecretOption( + string propertyName, + bool isFlag, + string description) => + base.IsSecretOption(propertyName, isFlag, description) || (!isFlag && (propertyName.Equals("NewKey", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("OldKey", StringComparison.OrdinalIgnoreCase) || diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/DockerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/DockerCliScraper.cs index c3a74703338..d1eeb4284e1 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/DockerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/DockerCliScraper.cs @@ -31,4 +31,13 @@ public DockerCliScraper(ICliCommandExecutor executor, IHelpTextCache helpCache, string[] commandPath, string helpText) => DockerCliCompatibility.DetectCommandGroupAlias(commandPath, helpText); + + /// + protected override string NormalizeOptionSwitchName( + string[] commandParts, + string switchName) => + commandParts is ["compose", "exec"] + && switchName.Equals("--no-tty", StringComparison.OrdinalIgnoreCase) + ? "--no-TTY" + : switchName; } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TrivyCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TrivyCliScraper.cs index 97916f0c083..a0527964399 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TrivyCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TrivyCliScraper.cs @@ -122,7 +122,7 @@ private static IReadOnlyList CreatePluginRunArguments( { CSharpType = "IEnumerable?", Phase = CommandLinePhase.Passthrough, - PositionIndex = 1, + PositionIndex = 0, }, ]; } @@ -148,15 +148,33 @@ private static IReadOnlyList CreateMissingPositionalArgum [RequiredArgument("Source")], ["registry", "login"] or ["registry", "logout"] => [RequiredArgument("Server")], + ["module"] or ["plugin"] or ["registry"] or ["vex"] or ["vex", "repo"] => + [OptionalArgument("Command") with { Description = "The command operand." }], _ => positionalArguments, }; protected override string NormalizeOptionDescription(string description) { - var normalizedDescription = UserHomeDirectoryPattern().Replace(description, ""); + var normalizedDescription = NormalizeHomePathSeparators( + UserHomeDirectoryPattern().Replace(description, "")); return TrivyCacheDirectoryPattern().Replace(normalizedDescription, "/trivy"); } + private static string NormalizeHomePathSeparators(string description) + { + const string homePlaceholder = ""; + var homeEnd = description.IndexOf(homePlaceholder, StringComparison.Ordinal); + if (homeEnd < 0) + { + return description; + } + + homeEnd += homePlaceholder.Length; + return string.Concat( + description.AsSpan(0, homeEnd), + PathSeparatorPattern().Replace(description[homeEnd..], "/")); + } + private static CliPositionalArgument RequiredArgument(string propertyName) => new() { PropertyName = propertyName, @@ -177,4 +195,7 @@ private static CliPositionalArgument OptionalArgument(string propertyName) => [GeneratedRegex(@"(?i)(?:[\\/]+AppData[\\/]+Local|[\\/]+\.cache|[\\/]+Library[\\/]+Caches)[\\/]+trivy")] private static partial Regex TrivyCacheDirectoryPattern(); + + [GeneratedRegex(@"[\\/]+")] + private static partial Regex PathSeparatorPattern(); }