From 81c30d08da3c329881c1a73906327818be85c98e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:17:21 +0100 Subject: [PATCH 1/6] fix(generator): preserve required synopsis branches --- .../GcloudNestedSynopsisValidationTests.cs | 75 ++++++++++++++++++ .../GcloudPeerResourceValidationTests.cs | 4 + .../Cli/GcloudSynopsisGroupReconcilerTests.cs | 25 ++++++ .../Scrapers/Cli/GcloudCliScraper.cs | 69 +++++++++++++++- .../Scrapers/Cli/UsageSynopsisParser.cs | 79 +++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs new file mode 100644 index 00000000000..12cbd9febf8 --- /dev/null +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs @@ -0,0 +1,75 @@ +using ModularPipelines.OptionsGenerator.Tests.Scrapers.Cli; + +namespace ModularPipelines.OptionsGenerator.Tests.Generators; + +public partial class RequiredConstructorValidationTests +{ + [Test] + public async Task Gcloud_Storage_Source_Branches_Keep_Bucket_And_Filter_Choices_Together() + { + var command = await GcloudCapturedSemanticsTests.Scrape("storage batch-operations jobs create"); + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("Bucket")); + await ValidateCapturedGroup(command, group, + [ + ("", false), + ("Bucket,ManifestLocation", true), + ("BucketList,ManifestLocation", true), + ("Bucket,IncludedObjectPrefixes", true), + ("BucketList,IncludedObjectPrefixes", true), + ("Bucket", false), + ("ManifestLocation", false), + ("Bucket,BucketList,ManifestLocation", false), + ("Bucket,ManifestLocation,IncludedObjectPrefixes", false), + ("DryRunJobId", true), + ("InsightsDataSetConfig,TargetProject", true), + ("InsightsDataSetConfig", false), + ("TargetProject", false), + ("InsightsDataSetConfig,TargetProject,TargetLocations,TargetSnapshotTime", true), + ("Bucket,ManifestLocation,DryRunJobId", false), + ]); + } + + [Test] + public async Task Gcloud_Storage_Custom_Context_Alternatives_Preserve_Required_Pairs() + { + var command = await GcloudCapturedSemanticsTests.Scrape("storage batch-operations jobs create"); + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("ClearAllObjectCustomContexts")); + await ValidateCapturedGroup(command, group, + [ + ("", false), + ("ClearAllObjectCustomContexts", true), + ("ClearObjectCustomContexts,UpdateObjectCustomContexts", true), + ("ClearObjectCustomContexts", false), + ("UpdateObjectCustomContexts", false), + ("UpdateObjectCustomContextsFile", true), + ("ClearAllObjectCustomContexts,UpdateObjectCustomContextsFile", false), + ("DeleteObject", true), + ("DeleteObject,EnablePermanentObjectDeletion", true), + ("EnablePermanentObjectDeletion", false), + ]); + } + + [Test] + public async Task Gcloud_Agent_Identity_Oauth_Requires_Every_Selected_Branch_Member() + { + var command = await GcloudCapturedSemanticsTests.Scrape("agent-identity auth-providers create"); + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("ApiKey")); + const string threeLegged = "ThreeLeggedOauthAuthorizationUrl,ThreeLeggedOauthClientId,ThreeLeggedOauthClientSecret,ThreeLeggedOauthDefaultContinueUri,ThreeLeggedOauthEnablePkce,ThreeLeggedOauthTokenUrl"; + const string twoLegged = "TwoLeggedOauthClientId,TwoLeggedOauthClientSecret,TwoLeggedOauthTokenUrl"; + var cases = new List<(string Properties, bool Valid)> + { + ("", false), + ("ApiKey", true), + (threeLegged, true), + (twoLegged, true), + ("ApiKey," + twoLegged, false), + }; + foreach (var branch in new[] { threeLegged, twoLegged }) + { + var members = branch.Split(','); + cases.AddRange(members.Select(missing => (string.Join(',', members.Where(member => member != missing)), false))); + } + + await ValidateCapturedGroup(command, group, [.. cases]); + } +} diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs index c1cf5ef6e27..6c941459349 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs @@ -147,6 +147,10 @@ private static async Task ValidateCapturedGroup(CliCommandDefinition command, Cl { value = 1; } + else if (property.PropertyType == typeof(IEnumerable)) + { + value = new[] { "value" }; + } property.SetValue(instance, value); } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs index 70166b8e9a3..cf9f1887b43 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs @@ -5,6 +5,31 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers.Cli; public class GcloudSynopsisGroupReconcilerTests { + [Test] + [Arguments("tool run [--first=FIRST | --second=SECOND]")] + [Arguments("tool run (--first=FIRST : --selector=SELECTOR | --second=SECOND)")] + [Arguments("tool run (--first=FIRST | OPERAND)")] + [Arguments("tool run (--first=FIRST; default=one | --second=SECOND)")] + public async Task Required_Option_Constraints_Exclude_Optional_Ambiguous_And_Operand_Syntax(string synopsis) + { + await Assert.That(UsageSynopsisParser.GetRequiredOptionChoiceGroups(synopsis)).IsEmpty(); + } + + [Test] + public async Task Required_Option_Constraints_Preserve_Nested_Choices_And_Optional_Selectors() + { + var group = UsageSynopsisParser.GetRequiredOptionChoiceGroups( + "tool run ((--first=FIRST | --second=SECOND) (--file=FILE | --prefix=PREFIX) | [--resource=RESOURCE : --selector=SELECTOR])").Single(); + await Assert.That(group.IsChoice).IsTrue(); + await Assert.That(group.IsRequired).IsTrue(); + await Assert.That(group.Groups[0].Groups.Count).IsEqualTo(2); + await Assert.That(group.Groups[0].Groups.All(choice => choice.IsChoice && choice.IsRequired)).IsTrue(); + var resource = group.Groups[1]; + await Assert.That(resource.IsRequired).IsFalse(); + await Assert.That(resource.Members[0].IsRequired).IsTrue(); + await Assert.That(resource.Members[1].IsRequired).IsFalse(); + } + [Test] public async Task Resource_Bundles_Identify_Their_Single_Primary_Option() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs index 21f760cdd69..99e65834d1e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs @@ -159,6 +159,7 @@ protected override IEnumerable ExtractSubcommands(string helpText) protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandPath, string helpText) { + string? argumentGroupSynopsis = null; var groups = ExtractSections(helpText, "FLAGS", "REQUIRED FLAGS", "OPTIONAL FLAGS", "POSITIONAL ARGUMENTS") .Select(section => ParseSectionArgumentGroup(section.Name, section.Content)).ToArray(); var declaredArguments = groups.SelectMany(group => group.FlattenArguments()).ToArray(); @@ -184,6 +185,7 @@ protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandP // Defaults annotate the preceding option; they do not add operands or // change the nesting of option groups in the synopsis. normalized = SynopsisDefaultAnnotationPattern().Replace(normalized, "${option} "); + argumentGroupSynopsis ??= normalized; normalized = UsageSynopsisParser.DeferDocumentedOptionGroups(normalized, groups); helpText = helpText.Replace(synopsis, normalized, StringComparison.Ordinal); } @@ -192,7 +194,10 @@ protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandP .Where(argument => argument.IsPositional) .Select(argument => NormalizePropertyName(argument.SwitchName)!)); return UsageSynopsisParser.RemoveCommandGroupPlaceholders( - base.ParseUsageSynopsis(commandPath, helpText), dispatchPlaceholders); + base.ParseUsageSynopsis(commandPath, helpText), dispatchPlaceholders) with + { + ArgumentGroupSynopsis = argumentGroupSynopsis, + }; } protected override UsageSynopsisParseResult NormalizeUsageSynopsis( @@ -347,9 +352,71 @@ private static List ExtractFromSection(string helpText, string sectionNa name == "REQUIRED FLAGS", allowPresenceRequirements: name != "OPTIONAL FLAGS"); } + ReconcileRequiredSynopsisChoices(usage.ArgumentGroupSynopsis ?? usage.Synopsis, options, requiredAlternativeGroups); + return (options, argumentGroups, requiredAlternativeGroups, positionalArguments); } + private static void ReconcileRequiredSynopsisChoices(string? synopsis, IReadOnlyList options, + List constraints) + { + foreach (var syntax in UsageSynopsisParser.GetRequiredOptionChoiceGroups(synopsis)) + { + var switches = syntax.EnumerateMembers().Select(member => member.OptionSwitch!).ToArray(); + if (switches.Distinct(StringComparer.Ordinal).Count() != switches.Length + || switches.Any(optionSwitch => !options.Any(option => option.SwitchName == optionSwitch))) + { + continue; + } + + var replacement = Convert(syntax); + var names = replacement.PropertyNames.ToHashSet(StringComparer.Ordinal); + // Explicit required synopsis choices preserve both nested alternatives and + // conjunctions. Replace only a complete matching constraint; keep help prose + // and partially documented groups under the existing scraper rules. + var index = constraints.FindIndex(group => names.SetEquals(group.PropertyNames)); + if (index >= 0) + { + constraints[index] = replacement; + } + } + + CliRequiredAlternativeGroup Convert(UsageRequiredAlternativeGroup syntax) + { + var groups = syntax.Groups.Select(Convert).ToList(); + var members = new List(); + foreach (var member in syntax.Members) + { + var alternatives = GetRequiredAlternativeMembers(new CliArgumentDefinition + { + SwitchName = member.OptionSwitch!, + }, options, []).ToArray(); + if (alternatives.Length > 1) + { + groups.Add(new CliRequiredAlternativeGroup + { + IsRequired = member.IsRequired, + IsMutuallyExclusive = true, + Members = alternatives, + }); + } + else + { + members.AddRange(alternatives.Select(alternative => alternative with { IsRequired = member.IsRequired })); + } + } + + return new CliRequiredAlternativeGroup + { + IsRequired = syntax.IsRequired, + IsChoice = syntax.IsChoice, + IsMutuallyExclusive = syntax.IsChoice, + Members = members, + Groups = groups, + }; + } + } + private static CliArgumentGroup MarkOptionalResourceGroups(CliArgumentGroup group, IReadOnlyList> optionalGroups) { var arguments = group.FlattenArguments().ToArray(); diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs index 5f561bcc46b..64c9d6ef787 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs @@ -1426,6 +1426,82 @@ static IEnumerable>> Visit(string token) } } + internal static IEnumerable GetRequiredOptionChoiceGroups(string? synopsis) => + synopsis is null ? [] : Tokenize(synopsis) + .Where(token => token.StartsWith('(') && IsWrapped(token)) + .Select(token => ParseOptionConstraint(token, true)) + .OfType() + .Where(group => group.IsChoice); + + private static UsageRequiredAlternativeGroup? ParseOptionConstraint(string text, bool required) + { + if (IsWrapped(text) && Tokenize(text).Count == 1) + { + required &= !text.StartsWith('['); + text = TrimWrapper(text); + } + + var tokens = TokenizeOptionGroup(text); + if (!ContainsOnlyInlineOptions(tokens) || (tokens.Contains(":") && tokens.Contains("|"))) + { + return null; + } + + var alternatives = SplitTopLevelAlternatives(text); + if (alternatives.Count > 1) + { + var branches = alternatives.Select(branch => ParseOptionConstraint(branch, true)).ToArray(); + return branches.Any(branch => branch is null) ? null : new UsageRequiredAlternativeGroup + { + IsRequired = required, + Members = [], + Groups = [.. branches.OfType()], + }; + } + + return ParseOptionBundle(tokens, required); + } + + private static UsageRequiredAlternativeGroup? ParseOptionBundle(IReadOnlyList tokens, bool required) + { + var members = new List(); + var groups = new List(); + var requiredMember = true; + foreach (var token in tokens) + { + if (token == ":") + { + requiredMember = false; + } + else if (IsWrapped(token)) + { + var nested = ParseOptionConstraint(token, requiredMember); + if (nested is null) + { + return null; + } + + groups.Add(nested); + } + else + { + members.AddRange(GetOptionSwitches(token).Select(optionSwitch => new UsageRequiredAlternativeMember + { + OptionSwitch = optionSwitch.Replace("--[no-]", "--", StringComparison.Ordinal), + IsRequired = requiredMember, + })); + } + } + + return new UsageRequiredAlternativeGroup + { + IsRequired = required, + IsChoice = false, + Members = members, + Groups = groups, + }; + } + internal static IEnumerable> GetOptionalResourceOptionGroups(string? synopsis, IReadOnlyList? documentedGroups = null) { @@ -2187,6 +2263,9 @@ private sealed record CommandMatch(int EndIndex, int PartCount); /// public sealed record UsageSynopsisParseResult { + // Retains option-group syntax that a tool defers while parsing positional operands. + internal string? ArgumentGroupSynopsis { get; init; } + internal static UsageSynopsisParseResult Empty { get; } = new(); internal static UsageSynopsisParseResult Unmatched(string synopsis) => new() From ae52fba7e62057dae63931ec3f0e375c31abec59 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:33:33 +0100 Subject: [PATCH 2/6] fix(generator): retain documented choice semantics and selected synopsis --- .../Cli/GcloudSynopsisGroupReconcilerTests.cs | 29 ++++++++++ .../Scrapers/Cli/GcloudCliScraper.cs | 55 ++++++++++++++++--- .../Scrapers/Cli/UsageSynopsisParser.cs | 6 +- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs index cf9f1887b43..be7cefd4794 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs @@ -5,9 +5,38 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers.Cli; public class GcloudSynopsisGroupReconcilerTests { + [Test] + public async Task Required_Option_Constraints_Use_The_Selected_Synopsis_Form() + { + const string help = """ + NAME + gcloud example run - run an example + SYNOPSIS + gcloud example run ITEM (--first=FIRST --second=SECOND | --third=THIRD) + gcloud example run (--first=FIRST | --second=SECOND --third=THIRD) + POSITIONAL ARGUMENTS + [ITEM] + Optional item. + FLAGS + Exactly one of these must be specified: + --first=FIRST + First value. + --second=SECOND + Second value. + --third=THIRD + Third value. + """; + var command = (await GcloudResourceArgumentTests.ScrapeFixture("example run", help)).Single(); + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("First")); + await Assert.That(command.UsageSynopsis).Contains("ITEM"); + await Assert.That(group.Members.Select(member => member.PropertyName)).IsEquivalentTo(["Third"]); + await Assert.That(group.Groups.Single().Members.Select(member => member.PropertyName)).IsEquivalentTo(["First", "Second"]); + } + [Test] [Arguments("tool run [--first=FIRST | --second=SECOND]")] [Arguments("tool run (--first=FIRST : --selector=SELECTOR | --second=SECOND)")] + [Arguments("tool run (--first=FIRST:--selector=SELECTOR|--second=SECOND)")] [Arguments("tool run (--first=FIRST | OPERAND)")] [Arguments("tool run (--first=FIRST; default=one | --second=SECOND)")] public async Task Required_Option_Constraints_Exclude_Optional_Ambiguous_And_Operand_Syntax(string synopsis) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs index 99e65834d1e..e79bb52fc1a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs @@ -159,7 +159,7 @@ protected override IEnumerable ExtractSubcommands(string helpText) protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandPath, string helpText) { - string? argumentGroupSynopsis = null; + var argumentGroupSynopses = new List<(string Deferred, string Original)>(); var groups = ExtractSections(helpText, "FLAGS", "REQUIRED FLAGS", "OPTIONAL FLAGS", "POSITIONAL ARGUMENTS") .Select(section => ParseSectionArgumentGroup(section.Name, section.Content)).ToArray(); var declaredArguments = groups.SelectMany(group => group.FlattenArguments()).ToArray(); @@ -185,19 +185,31 @@ protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandP // Defaults annotate the preceding option; they do not add operands or // change the nesting of option groups in the synopsis. normalized = SynopsisDefaultAnnotationPattern().Replace(normalized, "${option} "); - argumentGroupSynopsis ??= normalized; - normalized = UsageSynopsisParser.DeferDocumentedOptionGroups(normalized, groups); + var candidates = UsageSynopsisParser.ExtractSynopses("SYNOPSIS\n" + normalized, ["SYNOPSIS"]) + .Select(candidate => (Deferred: UsageSynopsisParser.DeferDocumentedOptionGroups(candidate, groups), Original: candidate)) + .ToArray(); + argumentGroupSynopses.AddRange(candidates); + // Keep one invocation per line after deferral: removed multiline groups + // must not leave blank lines that prematurely terminate the synopsis. + normalized = candidates.Length == 0 ? normalized + : "\n" + string.Join('\n', candidates.Select(candidate => " " + candidate.Deferred)) + "\n\n"; helpText = helpText.Replace(synopsis, normalized, StringComparison.Ordinal); } var dispatchPlaceholders = new HashSet(StringComparer.OrdinalIgnoreCase) { "Group", "Command" }; dispatchPlaceholders.ExceptWith(declaredArguments .Where(argument => argument.IsPositional) .Select(argument => NormalizePropertyName(argument.SwitchName)!)); - return UsageSynopsisParser.RemoveCommandGroupPlaceholders( - base.ParseUsageSynopsis(commandPath, helpText), dispatchPlaceholders) with + var usage = UsageSynopsisParser.RemoveCommandGroupPlaceholders( + base.ParseUsageSynopsis(commandPath, helpText), dispatchPlaceholders); + var selected = argumentGroupSynopses + .Where(candidate => NormalizeWhitespace(candidate.Deferred) == NormalizeWhitespace(usage.Synopsis ?? "")) + .Select(candidate => candidate.Original).Distinct(StringComparer.Ordinal).ToArray(); + return usage with { - ArgumentGroupSynopsis = argumentGroupSynopsis, + ArgumentGroupSynopsis = selected is [var selectedSynopsis] ? selectedSynopsis : null, }; + + static string NormalizeWhitespace(string value) => string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); } protected override UsageSynopsisParseResult NormalizeUsageSynopsis( @@ -377,10 +389,31 @@ private static void ReconcileRequiredSynopsisChoices(string? synopsis, IReadOnly var index = constraints.FindIndex(group => names.SetEquals(group.PropertyNames)); if (index >= 0) { - constraints[index] = replacement; + var previous = constraints[index]; + constraints[index] = PreserveDocumentedChoices(replacement, Descendants(previous).ToArray()) + with + { IsRequired = previous.IsRequired }; } } + static IEnumerable Descendants(CliRequiredAlternativeGroup group) => + new[] { group }.Concat(group.Groups.SelectMany(Descendants)); + + static CliRequiredAlternativeGroup PreserveDocumentedChoices(CliRequiredAlternativeGroup group, + IReadOnlyList documented) + { + // Colon syntax can hide a documented, nonexclusive "at least one" rule. + // Preserve that cardinality instead of requiring every member of the bundle. + var choice = group.IsChoice ? null : documented.FirstOrDefault(candidate => candidate.IsChoice + && !candidate.IsMutuallyExclusive + && candidate.Members.All(member => !member.IsRequired) + && group.PropertyNames.ToHashSet(StringComparer.Ordinal).SetEquals(candidate.PropertyNames)); + return choice ?? group with + { + Groups = [.. group.Groups.Select(child => PreserveDocumentedChoices(child, documented))], + }; + } + CliRequiredAlternativeGroup Convert(UsageRequiredAlternativeGroup syntax) { var groups = syntax.Groups.Select(Convert).ToList(); @@ -406,6 +439,14 @@ CliRequiredAlternativeGroup Convert(UsageRequiredAlternativeGroup syntax) } } + if (syntax.IsChoice) + { + var scalarBranches = groups.Where(group => !group.IsChoice && group.Groups.Count == 0 + && group.Members.Count == 1).ToArray(); + members.AddRange(scalarBranches.Select(group => group.Members[0] with { IsRequired = false })); + groups.RemoveAll(scalarBranches.Contains); + } + return new CliRequiredAlternativeGroup { IsRequired = syntax.IsRequired, diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs index 64c9d6ef787..b2a32832fb4 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs @@ -965,7 +965,7 @@ internal static bool IsPositionalSlot( return optionIndex >= 0 && options[optionIndex].IsFlag; } - private static List ExtractSynopses( + internal static List ExtractSynopses( string helpText, IReadOnlyList acceptedHeadings) { @@ -1442,12 +1442,12 @@ internal static IEnumerable GetRequiredOptionChoi } var tokens = TokenizeOptionGroup(text); - if (!ContainsOnlyInlineOptions(tokens) || (tokens.Contains(":") && tokens.Contains("|"))) + var alternatives = SplitTopLevelAlternatives(text); + if (!ContainsOnlyInlineOptions(tokens) || (tokens.Contains(":") && alternatives.Count > 1)) { return null; } - var alternatives = SplitTopLevelAlternatives(text); if (alternatives.Count > 1) { var branches = alternatives.Select(branch => ParseOptionConstraint(branch, true)).ToArray(); From c8f0dd3987c188d882b6abd8b2415435b200d583 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:38:21 +0100 Subject: [PATCH 3/6] refactor(generator): separate synopsis constraint helpers --- .../Scrapers/Cli/GcloudCliScraper.cs | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs index e79bb52fc1a..ebd7a4fdb17 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs @@ -381,7 +381,7 @@ private static void ReconcileRequiredSynopsisChoices(string? synopsis, IReadOnly continue; } - var replacement = Convert(syntax); + var replacement = ConvertSynopsisConstraint(syntax, options); var names = replacement.PropertyNames.ToHashSet(StringComparer.Ordinal); // Explicit required synopsis choices preserve both nested alternatives and // conjunctions. Replace only a complete matching constraint; keep help prose @@ -390,72 +390,72 @@ private static void ReconcileRequiredSynopsisChoices(string? synopsis, IReadOnly if (index >= 0) { var previous = constraints[index]; - constraints[index] = PreserveDocumentedChoices(replacement, Descendants(previous).ToArray()) - with - { IsRequired = previous.IsRequired }; + replacement = PreserveDocumentedChoices(replacement, EnumerateConstraints(previous).ToArray()); + constraints[index] = replacement with { IsRequired = previous.IsRequired }; } } + } - static IEnumerable Descendants(CliRequiredAlternativeGroup group) => - new[] { group }.Concat(group.Groups.SelectMany(Descendants)); + private static IEnumerable EnumerateConstraints(CliRequiredAlternativeGroup group) => + new[] { group }.Concat(group.Groups.SelectMany(EnumerateConstraints)); - static CliRequiredAlternativeGroup PreserveDocumentedChoices(CliRequiredAlternativeGroup group, - IReadOnlyList documented) + private static CliRequiredAlternativeGroup PreserveDocumentedChoices(CliRequiredAlternativeGroup group, + IReadOnlyList documented) + { + // Colon syntax can hide a documented, nonexclusive "at least one" rule. + // Preserve that cardinality instead of requiring every member of the bundle. + var choice = group.IsChoice ? null : documented.FirstOrDefault(candidate => candidate.IsChoice + && !candidate.IsMutuallyExclusive + && candidate.Members.All(member => !member.IsRequired) + && group.PropertyNames.ToHashSet(StringComparer.Ordinal).SetEquals(candidate.PropertyNames)); + return choice ?? group with { - // Colon syntax can hide a documented, nonexclusive "at least one" rule. - // Preserve that cardinality instead of requiring every member of the bundle. - var choice = group.IsChoice ? null : documented.FirstOrDefault(candidate => candidate.IsChoice - && !candidate.IsMutuallyExclusive - && candidate.Members.All(member => !member.IsRequired) - && group.PropertyNames.ToHashSet(StringComparer.Ordinal).SetEquals(candidate.PropertyNames)); - return choice ?? group with - { - Groups = [.. group.Groups.Select(child => PreserveDocumentedChoices(child, documented))], - }; - } + Groups = [.. group.Groups.Select(child => PreserveDocumentedChoices(child, documented))], + }; + } - CliRequiredAlternativeGroup Convert(UsageRequiredAlternativeGroup syntax) + private static CliRequiredAlternativeGroup ConvertSynopsisConstraint(UsageRequiredAlternativeGroup syntax, + IReadOnlyList options) + { + var groups = syntax.Groups.Select(child => ConvertSynopsisConstraint(child, options)).ToList(); + var members = new List(); + foreach (var member in syntax.Members) { - var groups = syntax.Groups.Select(Convert).ToList(); - var members = new List(); - foreach (var member in syntax.Members) + var alternatives = GetRequiredAlternativeMembers(new CliArgumentDefinition { - var alternatives = GetRequiredAlternativeMembers(new CliArgumentDefinition - { - SwitchName = member.OptionSwitch!, - }, options, []).ToArray(); - if (alternatives.Length > 1) - { - groups.Add(new CliRequiredAlternativeGroup - { - IsRequired = member.IsRequired, - IsMutuallyExclusive = true, - Members = alternatives, - }); - } - else + SwitchName = member.OptionSwitch!, + }, options, []).ToArray(); + if (alternatives.Length > 1) + { + groups.Add(new CliRequiredAlternativeGroup { - members.AddRange(alternatives.Select(alternative => alternative with { IsRequired = member.IsRequired })); - } + IsRequired = member.IsRequired, + IsMutuallyExclusive = true, + Members = alternatives, + }); } - - if (syntax.IsChoice) + else { - var scalarBranches = groups.Where(group => !group.IsChoice && group.Groups.Count == 0 - && group.Members.Count == 1).ToArray(); - members.AddRange(scalarBranches.Select(group => group.Members[0] with { IsRequired = false })); - groups.RemoveAll(scalarBranches.Contains); + members.AddRange(alternatives.Select(alternative => alternative with { IsRequired = member.IsRequired })); } + } - return new CliRequiredAlternativeGroup - { - IsRequired = syntax.IsRequired, - IsChoice = syntax.IsChoice, - IsMutuallyExclusive = syntax.IsChoice, - Members = members, - Groups = groups, - }; + if (syntax.IsChoice) + { + var scalarBranches = groups.Where(group => !group.IsChoice && group.Groups.Count == 0 + && group.Members.Count == 1).ToArray(); + members.AddRange(scalarBranches.Select(group => group.Members[0] with { IsRequired = false })); + groups.RemoveAll(scalarBranches.Contains); } + + return new CliRequiredAlternativeGroup + { + IsRequired = syntax.IsRequired, + IsChoice = syntax.IsChoice, + IsMutuallyExclusive = syntax.IsChoice, + Members = members, + Groups = groups, + }; } private static CliArgumentGroup MarkOptionalResourceGroups(CliArgumentGroup group, IReadOnlyList> optionalGroups) From 6739a906f68abbe753ccb2ca6c429e2225cdd763 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:58:10 +0100 Subject: [PATCH 4/6] fix(generator): preserve flattened documented choice branches --- .../GcloudNestedSynopsisValidationTests.cs | 31 +++++++++++++-- .../GcloudPeerResourceValidationTests.cs | 4 ++ .../Cli/GcloudResourceArgumentTests.cs | 2 +- .../Scrapers/Cli/GcloudCliScraper.cs | 39 +++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs index 12cbd9febf8..65e9c5f8a83 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs @@ -4,6 +4,29 @@ namespace ModularPipelines.OptionsGenerator.Tests.Generators; public partial class RequiredConstructorValidationTests { + [Test] + public async Task Gcloud_Build_Trigger_Updates_Preserve_Documented_Nested_Choices() + { + var help = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Gcloud", "585.0.0", + "gcloud-builds-triggers-update-github.txt")); + var command = (await new GcloudResourceArgumentTests.TestScraper().Parse(["builds", "triggers", "update", "github"], help))!; + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("TriggerConfig")); + await ValidateCapturedGroup(command, group, + [ + ("", false), + ("TriggerConfig", true), + ("BranchPattern", true), + ("TagPattern", true), + ("BuildConfig", true), + ("UpdateSubstitutions", true), + ("Description", true), + ("Description,BranchPattern,BuildConfig", true), + ("TriggerConfig,BranchPattern", false), + ("BranchPattern,TagPattern", false), + ("BuildConfig,InlineConfig", false), + ]); + } + [Test] public async Task Gcloud_Storage_Source_Branches_Keep_Bucket_And_Filter_Choices_Together() { @@ -30,7 +53,7 @@ await ValidateCapturedGroup(command, group, } [Test] - public async Task Gcloud_Storage_Custom_Context_Alternatives_Preserve_Required_Pairs() + public async Task Gcloud_Storage_Custom_Context_Alternatives_Preserve_Documented_Choices() { var command = await GcloudCapturedSemanticsTests.Scrape("storage batch-operations jobs create"); var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("ClearAllObjectCustomContexts")); @@ -39,8 +62,10 @@ await ValidateCapturedGroup(command, group, ("", false), ("ClearAllObjectCustomContexts", true), ("ClearObjectCustomContexts,UpdateObjectCustomContexts", true), - ("ClearObjectCustomContexts", false), - ("UpdateObjectCustomContexts", false), + ("ClearObjectCustomContexts", true), + ("UpdateObjectCustomContexts", true), + ("ClearObjectCustomContexts,UpdateObjectCustomContextsFile", true), + ("UpdateObjectCustomContexts,UpdateObjectCustomContextsFile", false), ("UpdateObjectCustomContextsFile", true), ("ClearAllObjectCustomContexts,UpdateObjectCustomContextsFile", false), ("DeleteObject", true), diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs index 6c941459349..ba493ed24a4 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudPeerResourceValidationTests.cs @@ -151,6 +151,10 @@ private static async Task ValidateCapturedGroup(CliCommandDefinition command, Cl { value = new[] { "value" }; } + else if (property.PropertyType == typeof(IReadOnlyList)) + { + value = new[] { new ModularPipelines.Models.KeyValue("key", "value", "=") }; + } property.SetValue(instance, value); } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudResourceArgumentTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudResourceArgumentTests.cs index 4956456e711..3ec84972d71 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudResourceArgumentTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudResourceArgumentTests.cs @@ -581,7 +581,7 @@ internal static async Task> ScrapeFixture(string path return commands; } - private sealed class TestScraper() : GcloudCliScraper( + internal sealed class TestScraper() : GcloudCliScraper( new UnusedExecutor(), new HelpTextCache(NullLogger.Instance), NullLogger.Instance) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs index ebd7a4fdb17..b6658ca0c21 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs @@ -402,6 +402,7 @@ private static IEnumerable EnumerateConstraints(Cli private static CliRequiredAlternativeGroup PreserveDocumentedChoices(CliRequiredAlternativeGroup group, IReadOnlyList documented) { + group = RestoreFlattenedDocumentedChoices(group, documented); // Colon syntax can hide a documented, nonexclusive "at least one" rule. // Preserve that cardinality instead of requiring every member of the bundle. var choice = group.IsChoice ? null : documented.FirstOrDefault(candidate => candidate.IsChoice @@ -458,6 +459,44 @@ private static CliRequiredAlternativeGroup ConvertSynopsisConstraint(UsageRequir }; } + private static CliRequiredAlternativeGroup RestoreFlattenedDocumentedChoices(CliRequiredAlternativeGroup group, + IReadOnlyList documented) + { + if (!group.IsChoice) + { + return group; + } + + // SYNOPSIS can omit the wrappers around a documented nonexclusive branch. + // Restore that branch only when it covers complete synopsis alternatives; + // a partial overlap cannot establish where the missing boundaries belong. + foreach (var candidate in documented.Where(candidate => candidate.IsChoice && !candidate.IsMutuallyExclusive + && candidate.Members.All(member => !member.IsRequired)).OrderByDescending(candidate => candidate.PropertyNames.Count)) + { + var names = candidate.PropertyNames.ToHashSet(StringComparer.Ordinal); + if (names.SetEquals(group.PropertyNames)) + { + continue; + } + + var members = group.Members.Where(member => names.Contains(member.PropertyName)).ToArray(); + var children = group.Groups.Where(child => child.PropertyNames.Any(names.Contains)).ToArray(); + if (members.Length + children.Length < 2 + || !names.SetEquals(members.Select(member => member.PropertyName).Concat(children.SelectMany(child => child.PropertyNames)))) + { + continue; + } + + group = group with + { + Members = [.. group.Members.Except(members)], + Groups = [.. group.Groups.Except(children), candidate], + }; + } + + return group; + } + private static CliArgumentGroup MarkOptionalResourceGroups(CliArgumentGroup group, IReadOnlyList> optionalGroups) { var arguments = group.FlattenArguments().ToArray(); From 6abefa1eae364712cefce66d0d47cd848434b91e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:07:37 +0100 Subject: [PATCH 5/6] fix(generator): reconcile the option-resolved gcloud synopsis --- .../Cli/GcloudSynopsisGroupReconcilerTests.cs | 31 +++++++++++++++++++ .../Scrapers/Cli/CliScraperBase.cs | 2 +- .../Scrapers/Cli/GcloudCliScraper.cs | 24 +++++++++----- .../Scrapers/Cli/UsageSynopsisParser.cs | 1 + 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs index be7cefd4794..174985eeb31 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/GcloudSynopsisGroupReconcilerTests.cs @@ -5,6 +5,37 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers.Cli; public class GcloudSynopsisGroupReconcilerTests { + [Test] + public async Task Required_Option_Constraints_Follow_Option_Resolved_Synopsis_Selection() + { + const string help = """ + NAME + gcloud example run - run an example + SYNOPSIS + gcloud example run --output VALUE (--first=FIRST --second=SECOND | --third=THIRD) + gcloud example run ITEM (--first=FIRST | --second=SECOND --third=THIRD) + POSITIONAL ARGUMENTS + [ITEM] + Optional item. + FLAGS + --output=OUTPUT + Output path. + Exactly one of these must be specified: + --first=FIRST + First value. + --second=SECOND + Second value. + --third=THIRD + Third value. + """; + var command = (await GcloudResourceArgumentTests.ScrapeFixture("example run", help)).Single(); + var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("First")); + await Assert.That(command.UsageSynopsis).Contains("ITEM"); + await Assert.That(command.PositionalArguments.Select(argument => argument.PropertyName)).IsEquivalentTo(["Item"]); + await Assert.That(group.Members.Select(member => member.PropertyName)).IsEquivalentTo(["First"]); + await Assert.That(group.Groups.Single().Members.Select(member => member.PropertyName)).IsEquivalentTo(["Second", "Third"]); + } + [Test] public async Task Required_Option_Constraints_Use_The_Selected_Synopsis_Form() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs index 5a408fc6e25..64cec36eb41 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1176,7 +1176,7 @@ protected IReadOnlyList GetPositionalArguments( .Select(argument => argument with { AssociatedOptionSwitch = null })]; } - private IReadOnlyList GetUsageOptions(IReadOnlyList options) + protected IReadOnlyList GetUsageOptions(IReadOnlyList options) { var globalOptions = EffectiveGlobalOptions; return globalOptions.Count == 0 ? options : [.. options, .. globalOptions]; diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs index b6658ca0c21..690d1a0f2ab 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/GcloudCliScraper.cs @@ -145,7 +145,7 @@ protected override IEnumerable ExtractSubcommands(string helpText) ArgumentGroups = parsedOptions.ArgumentGroups, RequiredAlternativeGroups = parsedOptions.RequiredAlternativeGroups, PositionalArguments = positionalArgs, - UsageSynopsis = usage.Synopsis, + UsageSynopsis = parsedOptions.Usage.Synopsis, SubDomainGroup = subDomain, Enums = enums }; @@ -201,14 +201,23 @@ protected override UsageSynopsisParseResult ParseUsageSynopsis(string[] commandP .Select(argument => NormalizePropertyName(argument.SwitchName)!)); var usage = UsageSynopsisParser.RemoveCommandGroupPlaceholders( base.ParseUsageSynopsis(commandPath, helpText), dispatchPlaceholders); - var selected = argumentGroupSynopses - .Where(candidate => NormalizeWhitespace(candidate.Deferred) == NormalizeWhitespace(usage.Synopsis ?? "")) - .Select(candidate => candidate.Original).Distinct(StringComparer.Ordinal).ToArray(); return usage with { - ArgumentGroupSynopsis = selected is [var selectedSynopsis] ? selectedSynopsis : null, + ArgumentGroupSynopsis = FindArgumentGroupSynopsis(usage.Synopsis), + RequirednessCandidates = [.. usage.RequirednessCandidates.Select(candidate => candidate with + { + ArgumentGroupSynopsis = FindArgumentGroupSynopsis(candidate.Synopsis), + })], }; + string? FindArgumentGroupSynopsis(string? synopsis) + { + var selected = argumentGroupSynopses + .Where(candidate => NormalizeWhitespace(candidate.Deferred) == NormalizeWhitespace(synopsis ?? "")) + .Select(candidate => candidate.Original).Distinct(StringComparer.Ordinal).ToArray(); + return selected is [var selectedSynopsis] ? selectedSynopsis : null; + } + static string NormalizeWhitespace(string value) => string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); } @@ -314,7 +323,7 @@ private static List ExtractFromSection(string helpText, string sectionNa private (List Options, IReadOnlyList ArgumentGroups, IReadOnlyList RequiredAlternativeGroups, - IReadOnlyList PositionalArguments) ParseArguments( + IReadOnlyList PositionalArguments, UsageSynopsisParseResult Usage) ParseArguments( string helpText, IReadOnlyList commandParts, string[] commandPath, @@ -357,6 +366,7 @@ private static List ExtractFromSection(string helpText, string sectionNa } } + usage = UsageSynopsisParser.ResolveOptionUsage(usage, GetUsageOptions(options)); var positionalArguments = ParsePositionalArguments(usage, commandPath, argumentGroups, options); foreach (var (name, argumentGroup) in sections) { @@ -366,7 +376,7 @@ private static List ExtractFromSection(string helpText, string sectionNa ReconcileRequiredSynopsisChoices(usage.ArgumentGroupSynopsis ?? usage.Synopsis, options, requiredAlternativeGroups); - return (options, argumentGroups, requiredAlternativeGroups, positionalArguments); + return (options, argumentGroups, requiredAlternativeGroups, positionalArguments, usage); } private static void ReconcileRequiredSynopsisChoices(string? synopsis, IReadOnlyList options, diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs index b2a32832fb4..ea3ff203765 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/UsageSynopsisParser.cs @@ -826,6 +826,7 @@ internal static UsageSynopsisParseResult ResolveOptionUsage( usage = usage with { Synopsis = selected.Synopsis, + ArgumentGroupSynopsis = selected.ArgumentGroupSynopsis, HasOperandTokens = selected.PositionalArguments.Count > 0 || selected.UnparsedOperandTokens.Count > 0, PositionalArguments = selected.PositionalArguments, UnparsedOperandTokens = selected.UnparsedOperandTokens, From 4fe7feff52cc5ec6f00a9a0810950443ae3b7911 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:13:41 +0100 Subject: [PATCH 6/6] test(generator): use the full gcloud invocation path --- .../Generators/GcloudNestedSynopsisValidationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs index 65e9c5f8a83..3c004e686e0 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/GcloudNestedSynopsisValidationTests.cs @@ -9,7 +9,7 @@ public async Task Gcloud_Build_Trigger_Updates_Preserve_Documented_Nested_Choice { var help = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Gcloud", "585.0.0", "gcloud-builds-triggers-update-github.txt")); - var command = (await new GcloudResourceArgumentTests.TestScraper().Parse(["builds", "triggers", "update", "github"], help))!; + var command = (await new GcloudResourceArgumentTests.TestScraper().Parse(["gcloud", "builds", "triggers", "update", "github"], help))!; var group = command.RequiredAlternativeGroups.Single(group => group.PropertyNames.Contains("TriggerConfig")); await ValidateCapturedGroup(command, group, [