From 6871f717f88bdb96067672b3fbda4ae46d9e5f4d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:51:46 +0100 Subject: [PATCH 01/15] fix(generator): model repeatable options Derive collection shapes from shared repeatability markers across Terraform, Pip, Packer, and Snyk adapters. Fixes #3988 --- .../Scrapers/RepeatableOptionAdapterTests.cs | 133 ++++++++++++++++++ .../Scrapers/Cli/PackerCliScraper.cs | 5 +- .../Scrapers/Cli/PipCliScraper.cs | 5 +- .../Scrapers/Cli/SnykCliScraper.cs | 10 +- .../Scrapers/Cli/TerraformCliScraper.cs | 6 +- 5 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs new file mode 100644 index 00000000000..8af52d0a98f --- /dev/null +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ModularPipelines.OptionsGenerator.Models; +using ModularPipelines.OptionsGenerator.Scrapers.Cli; +using ModularPipelines.OptionsGenerator.TypeDetection; + +namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; + +public class RepeatableOptionAdapterTests +{ + private static ICliCommandExecutor Executor { get; } = + new ProcessCliCommandExecutor(NullLogger.Instance); + + private static IHelpTextCache Cache { get; } = + new HelpTextCache(NullLogger.Instance); + + [Test] + public async Task Terraform_Recognizes_Multiple_Times_Prose() + { + const string helpText = """ + Usage: terraform apply [options] + + Options: + -var-file=path Set variables from a file. This flag can be used multiple times. + """; + var command = await new TestTerraformCliScraper().Parse(["terraform", "apply"], helpText); + + await AssertRepeatable(command, "-var-file"); + } + + [Test] + public async Task Pip_Recognizes_Multiline_Multiple_Times_Prose() + { + const string helpText = """ + Usage: pip freeze [options] + + General Options: + -r, --requirement Install from the given requirements file. + This option can be used multiple times. + """; + var command = await new TestPipCliScraper().Parse(["pip", "freeze"], helpText); + + await AssertRepeatable(command, "--requirement"); + } + + [Test] + public async Task Packer_Recognizes_Repeatable_Prose() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var-file=path Set a variable file; repeatable for additional files. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var-file"); + } + + [Test] + public async Task Snyk_Recognizes_Repeated_Prose() + { + const string helpText = """ + Usage: snyk monitor [] + + Options + --project-environment= + Set the project environment. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "monitor"], helpText); + + await AssertRepeatable(command, "--project-environment"); + } + + private static async Task AssertRepeatable( + CliCommandDefinition? command, + string switchName) + { + var option = command!.Options.Single(item => item.SwitchName == switchName); + using (Assert.Multiple()) + { + await Assert.That(option.AcceptsMultipleValues).IsTrue(); + await Assert.That(option.CSharpType).IsEqualTo("IEnumerable?"); + } + } + + private sealed class TestTerraformCliScraper() + : TerraformCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } + + private sealed class TestPipCliScraper() + : PipCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync(commandPath, helpText, CancellationToken.None); + } + + private sealed class TestPackerCliScraper() + : PackerCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } + + private sealed class TestSnykCliScraper() + : SnykCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync(commandPath, helpText, CancellationToken.None); + } +} diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index a9e9d488032..fa5ed43111b 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -212,7 +212,8 @@ private List ParseOptions(string helpText) } var isFlag = string.IsNullOrEmpty(valueHint) || valueHint.Contains("true") || valueHint.Contains("false"); - var csharpType = isFlag ? "bool?" : "string?"; + var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; options.Add(new CliOptionDefinition { @@ -223,7 +224,7 @@ private List ParseOptions(string helpText) Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = false, ValueSeparator = "=", diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs index b5b1e7432ed..a62537fc38b 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs @@ -274,7 +274,8 @@ private List ParseOptions(string helpText, string[] command } var isFlag = string.IsNullOrEmpty(valueHint) && IsBooleanOption(longForm, description); - var csharpType = isFlag ? "bool?" : "string?"; + var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; options.Add(new CliOptionDefinition { @@ -285,7 +286,7 @@ private List ParseOptions(string helpText, string[] command Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = false, ValueSeparator = isFlag ? " " : " ", diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs index 0b0a7ceded0..111db4c5747 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs @@ -321,6 +321,9 @@ private void ParseOptionLines( && !isNumeric && !ValueOptionsWithoutHelpPlaceholders.Contains(longForm); var isBoolean = IsBooleanValueHint(valueHint); + var acceptsMultipleValues = !isFlag + && !isBoolean + && DescriptionDeclaresRepeatableOption(description ?? string.Empty); var csharpType = isFlag || isBoolean ? "bool?" : isNumeric ? "int?" : "string?"; CliEnumDefinition? enumDef = null; @@ -345,6 +348,11 @@ private void ParseOptionLines( } } + if (acceptsMultipleValues) + { + csharpType = $"IEnumerable<{csharpType.TrimEnd('?')}>?"; + } + options.Add(new CliOptionDefinition { SwitchName = longForm, @@ -354,7 +362,7 @@ private void ParseOptionLines( Description = description, IsFlag = isFlag, IsRequired = description?.Contains("Required.", StringComparison.OrdinalIgnoreCase) == true, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = isNumeric, ValueSeparator = isFlag ? " " : "=", diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index c1d34c35bd8..b97e677ed86 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -315,7 +315,9 @@ private List ParseOptions(string helpText, string[] command isFlag = true; } - var csharpType = isFlag ? "bool?" : (isInteger ? "int?" : "string?"); + var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; + var csharpType = acceptsMultipleValues ? $"IEnumerable<{scalarType}>?" : $"{scalarType}?"; options.Add(new CliOptionDefinition { @@ -326,7 +328,7 @@ private List ParseOptions(string helpText, string[] command Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = isInteger, ValueSeparator = isFlag ? " " : "=", From 00987e91a1edc316c98fb7a5f5bf0a97b52df748 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:55:04 +0100 Subject: [PATCH 02/15] refactor(generator): reduce parser complexity --- .../Scrapers/Cli/TerraformCliScraper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index b97e677ed86..9dc51d73d00 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -315,7 +315,7 @@ private List ParseOptions(string helpText, string[] command isFlag = true; } - var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var acceptsMultipleValues = IsRepeatableValueOption(isFlag, description); var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; var csharpType = acceptsMultipleValues ? $"IEnumerable<{scalarType}>?" : $"{scalarType}?"; @@ -340,6 +340,9 @@ private List ParseOptions(string helpText, string[] command return options; } + private static bool IsRepeatableValueOption(bool isFlag, string description) => + !isFlag && DescriptionDeclaresRepeatableOption(description); + /// /// Checks if a value hint indicates a numeric type. /// From 7c8c234c9810dd3f4955fb8409b4604fc58275ae Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:36 +0100 Subject: [PATCH 03/15] test(options): cover repeatable typed values Refs #4006 --- .../Scrapers/RepeatableOptionAdapterTests.cs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 8af52d0a98f..4d73bdd21bf 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -71,15 +71,49 @@ Set the project environment. Can be repeated. await AssertRepeatable(command, "--project-environment"); } + [Test] + public async Task Snyk_Preserves_Numeric_Element_Type_For_Repeatable_Options() + { + const string helpText = """ + Usage: snyk test [] + + Options + --max-depth= + Set the maximum dependency depth. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "test"], helpText); + + await AssertRepeatable(command, "--max-depth", "IEnumerable?"); + } + + [Test] + public async Task Snyk_Preserves_Enum_Element_Type_For_Repeatable_Options() + { + const string helpText = """ + Usage: snyk test [] + + Options + --severity-threshold= + Report only vulnerabilities at the selected threshold. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "test"], helpText); + + await AssertRepeatable( + command, + "--severity-threshold", + "IEnumerable?"); + } + private static async Task AssertRepeatable( CliCommandDefinition? command, - string switchName) + string switchName, + string expectedType = "IEnumerable?") { var option = command!.Options.Single(item => item.SwitchName == switchName); using (Assert.Multiple()) { await Assert.That(option.AcceptsMultipleValues).IsTrue(); - await Assert.That(option.CSharpType).IsEqualTo("IEnumerable?"); + await Assert.That(option.CSharpType).IsEqualTo(expectedType); } } From 09fbe4719f0f05c8f750479d920e99501249e698 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:02:50 +0100 Subject: [PATCH 04/15] refactor(generator): extract terraform type shape --- .../Scrapers/Cli/TerraformCliScraper.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index 9dc51d73d00..31a4fa74a6f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -316,8 +316,7 @@ private List ParseOptions(string helpText, string[] command } var acceptsMultipleValues = IsRepeatableValueOption(isFlag, description); - var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; - var csharpType = acceptsMultipleValues ? $"IEnumerable<{scalarType}>?" : $"{scalarType}?"; + var csharpType = GetCSharpType(isFlag, isInteger, acceptsMultipleValues); options.Add(new CliOptionDefinition { @@ -343,6 +342,12 @@ private List ParseOptions(string helpText, string[] command private static bool IsRepeatableValueOption(bool isFlag, string description) => !isFlag && DescriptionDeclaresRepeatableOption(description); + private static string GetCSharpType(bool isFlag, bool isInteger, bool acceptsMultipleValues) + { + var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; + return acceptsMultipleValues ? $"IEnumerable<{scalarType}>?" : $"{scalarType}?"; + } + /// /// Checks if a value hint indicates a numeric type. /// From ea76cb3386f9af62db1a3b9692387c476370e4e6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:43:06 +0100 Subject: [PATCH 05/15] fix(generator): narrow repeatability prose Require explicit option-repeatability language so operational retry descriptions remain scalar. --- .../Scrapers/RepeatableOptionAdapterTests.cs | 19 +++++++++++++++++++ .../Scrapers/Cli/CliScraperBase.cs | 16 ++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 4d73bdd21bf..8ac5a615924 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -56,6 +56,25 @@ public async Task Packer_Recognizes_Repeatable_Prose() await AssertRepeatable(command, "--var-file"); } + [Test] + public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -retry-count=count Retry the operation multiple times before failing. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + var option = command!.Options.Single(item => item.SwitchName == "--retry-count"); + + using (Assert.Multiple()) + { + await Assert.That(option.AcceptsMultipleValues).IsFalse(); + await Assert.That(option.CSharpType).IsEqualTo("string?"); + } + } + [Test] public async Task Snyk_Recognizes_Repeated_Prose() { 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 2061ee64935..f6ba5e326f9 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1051,8 +1051,20 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) private static partial Regex ExplicitBooleanValuePattern(); [GeneratedRegex( - @"\b(?:one\s+or\s+more|zero\s+or\s+more|multiple\s+(?:times|values)|more\s+than\s+once|repeat(?:able|ed|edly)?)\b", - RegexOptions.IgnoreCase)] + """ + \b(?: + repeatable + |(?:can|may)\s+be\s+repeated + |(?:can|may)\s+be\s+ + (?:specified|supplied|provided|used|passed|set|given)\s+ + (?:multiple\s+times|more\s+than\s+once) + |(?:specify|supply|provide|use|pass|set|give)\s+ + (?:multiple\s+times|more\s+than\s+once) + |(?:one|zero)\s+or\s+more\s+values? + |multiple\s+values + )\b + """, + RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)] private static partial Regex RepeatableValuePattern(); [GeneratedRegex(@"\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~])")] From 6a1df555d8e9ebddfa1eef18d2c967fd50170248 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:11:32 +0100 Subject: [PATCH 06/15] fix(generator): qualify repeatability prose --- .../Scrapers/RepeatableOptionAdapterTests.cs | 21 +++++++++++++++++ .../Scrapers/Cli/CliScraperBase.cs | 23 +++++++++++++++---- .../Scrapers/Cli/PackerCliScraper.cs | 2 +- .../Scrapers/Cli/PipCliScraper.cs | 2 +- .../Scrapers/Cli/SnykCliScraper.cs | 7 +++--- .../Scrapers/Cli/TerraformCliScraper.cs | 5 +--- .../Scrapers/Cli/WinGetCliScraper.cs | 2 +- 7 files changed, 47 insertions(+), 15 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 8ac5a615924..687160d9855 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -75,6 +75,27 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() } } + [Test] + [Arguments("This argument must be repeated for each entry.", true)] + [Arguments("The value is repeated across runs.", true)] + [Arguments("Provide one or more paths to scan.", true)] + [Arguments("Validates multiple values against a schema.", false)] + public async Task Packer_Classifies_Explicit_Repeatability_Prose( + string description, + bool expected) + { + var helpText = $""" + Usage: packer build [options] + + Options: + -var-file=path {description} + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + var option = command!.Options.Single(item => item.SwitchName == "--var-file"); + + await Assert.That(option.AcceptsMultipleValues).IsEqualTo(expected); + } + [Test] public async Task Snyk_Recognizes_Repeated_Prose() { 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 f6ba5e326f9..d764940420c 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -971,6 +971,15 @@ protected static bool HelpDeclaresRepeatableOption( protected static bool DescriptionDeclaresRepeatableOption(string description) => RepeatableValuePattern().IsMatch(description); + /// + /// Returns whether a value-taking, non-Boolean option is described as repeatable. + /// + protected static bool IsRepeatableValueOption( + string description, + bool isFlag, + bool isBoolean = false) => + !isFlag && !isBoolean && DescriptionDeclaresRepeatableOption(description); + /// /// Parses indentation-based argument declarations into a reusable nested group model. /// The adapter only recognizes one tool-specific declaration line; traversal, @@ -1054,14 +1063,18 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) """ \b(?: repeatable - |(?:can|may)\s+be\s+repeated - |(?:can|may)\s+be\s+ + |(?:can|may|must|should)\s+be\s+repeated + |(?:is|are)\s+repeated + |(?:can|may|must|should)\s+be\s+ (?:specified|supplied|provided|used|passed|set|given)\s+ (?:multiple\s+times|more\s+than\s+once) |(?:specify|supply|provide|use|pass|set|give)\s+ - (?:multiple\s+times|more\s+than\s+once) - |(?:one|zero)\s+or\s+more\s+values? - |multiple\s+values + (?: + multiple\s+times + |more\s+than\s+once + |(?:one|zero)\s+or\s+more\s+[\w-]+ + |multiple\s+[\w-]+ + ) )\b """, RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)] diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index fa5ed43111b..41a07c0cdc4 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -212,7 +212,7 @@ private List ParseOptions(string helpText) } var isFlag = string.IsNullOrEmpty(valueHint) || valueHint.Contains("true") || valueHint.Contains("false"); - var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; options.Add(new CliOptionDefinition diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs index a62537fc38b..5a8f01750f2 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs @@ -274,7 +274,7 @@ private List ParseOptions(string helpText, string[] command } var isFlag = string.IsNullOrEmpty(valueHint) && IsBooleanOption(longForm, description); - var acceptsMultipleValues = !isFlag && DescriptionDeclaresRepeatableOption(description); + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; options.Add(new CliOptionDefinition diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs index 111db4c5747..91c5b26f776 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs @@ -321,9 +321,10 @@ private void ParseOptionLines( && !isNumeric && !ValueOptionsWithoutHelpPlaceholders.Contains(longForm); var isBoolean = IsBooleanValueHint(valueHint); - var acceptsMultipleValues = !isFlag - && !isBoolean - && DescriptionDeclaresRepeatableOption(description ?? string.Empty); + var acceptsMultipleValues = IsRepeatableValueOption( + description ?? string.Empty, + isFlag, + isBoolean); var csharpType = isFlag || isBoolean ? "bool?" : isNumeric ? "int?" : "string?"; CliEnumDefinition? enumDef = null; diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index 31a4fa74a6f..afb88b6bdb4 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -315,7 +315,7 @@ private List ParseOptions(string helpText, string[] command isFlag = true; } - var acceptsMultipleValues = IsRepeatableValueOption(isFlag, description); + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag, isBoolean); var csharpType = GetCSharpType(isFlag, isInteger, acceptsMultipleValues); options.Add(new CliOptionDefinition @@ -339,9 +339,6 @@ private List ParseOptions(string helpText, string[] command return options; } - private static bool IsRepeatableValueOption(bool isFlag, string description) => - !isFlag && DescriptionDeclaresRepeatableOption(description); - private static string GetCSharpType(bool isFlag, bool isInteger, bool acceptsMultipleValues) { var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs index 36d4dae8e7d..adc644b213a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs @@ -437,7 +437,7 @@ private List ParseArguments(string helpText, HashSet Date: Sun, 23 Aug 2026 21:31:57 +0100 Subject: [PATCH 07/15] fix(generator): preserve repeatable phrases --- .../Scrapers/Cli/CliScraperTraversalTests.cs | 24 +++++++++++++++++++ .../Scrapers/Cli/CliScraperBase.cs | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs index 406f8ab0b71..afae3b0b358 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs @@ -620,6 +620,30 @@ fake execute [flags] await Assert.That(tag.CSharpType).IsEqualTo("IEnumerable?"); } + [Test] + [Arguments("Accepts multiple values")] + [Arguments("One or more label selectors")] + public async Task SharedShapeInference_Preserves_Common_Repeatability_Phrases(string description) + { + var helpText = $""" + Execute a command. + + Usage: + fake execute [flags] + + Flags: + --tag string {description} + """; + var scraper = new TestCobraScraper(new StubExecutor( + new Dictionary(StringComparer.OrdinalIgnoreCase))); + + var command = await scraper.Parse(["fake", "execute"], helpText); + var tag = command!.Options.Single(); + + await Assert.That(tag.AcceptsMultipleValues).IsTrue(); + await Assert.That(tag.CSharpType).IsEqualTo("IEnumerable?"); + } + [Test] public async Task SharedShapeInference_Models_Optional_Cobra_Option_Values() { 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 d764940420c..b332ecd7b04 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1065,10 +1065,11 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) repeatable |(?:can|may|must|should)\s+be\s+repeated |(?:is|are)\s+repeated + |(?:one|zero)\s+or\s+more\s+[\w-]+ |(?:can|may|must|should)\s+be\s+ (?:specified|supplied|provided|used|passed|set|given)\s+ (?:multiple\s+times|more\s+than\s+once) - |(?:specify|supply|provide|use|pass|set|give)\s+ + |(?:accepts?|specify|supply|provide|use|pass|set|give)\s+ (?: multiple\s+times |more\s+than\s+once From 7d8365fbc5255c4e4b5d043977aacb12331fdbaf Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:47:44 +0100 Subject: [PATCH 08/15] fix(generator): reject operational counts --- .../Scrapers/RepeatableOptionAdapterTests.cs | 2 ++ .../Scrapers/Cli/CliScraperBase.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 687160d9855..00e1d3329bb 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -80,6 +80,8 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() [Arguments("The value is repeated across runs.", true)] [Arguments("Provide one or more paths to scan.", true)] [Arguments("Validates multiple values against a schema.", false)] + [Arguments("Retry the operation one or more times before failing.", false)] + [Arguments("This operation runs one or more attempts depending on configuration.", false)] public async Task Packer_Classifies_Explicit_Repeatability_Prose( string description, bool expected) 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 b332ecd7b04..6b53fbb1c92 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1065,7 +1065,7 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) repeatable |(?:can|may|must|should)\s+be\s+repeated |(?:is|are)\s+repeated - |(?:one|zero)\s+or\s+more\s+[\w-]+ + |\A(?:one|zero)\s+or\s+more\s+[\w-]+ |(?:can|may|must|should)\s+be\s+ (?:specified|supplied|provided|used|passed|set|given)\s+ (?:multiple\s+times|more\s+than\s+once) From e37254888fa4865cd41801db28d788b385f89901 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:03:06 +0100 Subject: [PATCH 09/15] fix(generator): harden repeatable detection --- .../Scrapers/RepeatableOptionAdapterTests.cs | 18 +++++++++ .../Scrapers/WinGetCliScraperTests.cs | 25 +++++++++++++ .../Scrapers/Cli/CliScraperBase.cs | 3 +- .../Scrapers/Cli/PackerCliScraper.cs | 37 ++++++++++++++++++- .../Scrapers/Cli/WinGetCliScraper.cs | 4 +- 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 00e1d3329bb..ec607ace53d 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -56,6 +56,21 @@ public async Task Packer_Recognizes_Repeatable_Prose() await AssertRepeatable(command, "--var-file"); } + [Test] + public async Task Packer_Recognizes_Multiline_Repeatable_Prose() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var-file=path Set a variable file. + Can be repeated for additional files. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var-file"); + } + [Test] public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() { @@ -82,6 +97,9 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() [Arguments("Validates multiple values against a schema.", false)] [Arguments("Retry the operation one or more times before failing.", false)] [Arguments("This operation runs one or more attempts depending on configuration.", false)] + [Arguments("One or more attempts are made before failure.", false)] + [Arguments("One or more times may be required.", false)] + [Arguments("One or more retries may be attempted.", false)] public async Task Packer_Classifies_Explicit_Repeatability_Prose( string description, bool expected) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs index bfc90695c52..1f613a87f8e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs @@ -49,6 +49,31 @@ List installed packages. } } + [Test] + public async Task Explicit_Repeatability_Takes_Precedence_Over_Boolean_Heuristics() + { + const string helpText = """ + List installed packages. + + usage: winget list [] + + The following options are available: + --source Accepts multiple values + """; + + var command = await new TestWinGetCliScraper().Parse( + ["winget", "list"], + helpText); + var source = command!.Options.Single(option => option.SwitchName == "--source"); + + using (Assert.Multiple()) + { + await Assert.That(source.IsFlag).IsFalse(); + await Assert.That(source.AcceptsMultipleValues).IsTrue(); + await Assert.That(source.CSharpType).IsEqualTo("IEnumerable?"); + } + } + [Test] public async Task Does_Not_Mark_Boolean_Flags_As_Repeatable_Values() { 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 6b53fbb1c92..77700d0137c 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1065,7 +1065,8 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) repeatable |(?:can|may|must|should)\s+be\s+repeated |(?:is|are)\s+repeated - |\A(?:one|zero)\s+or\s+more\s+[\w-]+ + |\A(?:one|zero)\s+or\s+more\s+ + (?!(?:attempts?|times?|retries?)\b)[\w-]+ |(?:can|may|must|should)\s+be\s+ (?:specified|supplied|provided|used|passed|set|given)\s+ (?:multiple\s+times|more\s+than\s+once) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index 41a07c0cdc4..acae8f7d88f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -179,8 +179,9 @@ private List ParseOptions(string helpText) var lines = section.Split('\n'); - foreach (var line in lines) + for (var i = 0; i < lines.Length; i++) { + var line = lines[i]; var match = PackerOptionPattern().Match(line); if (!match.Success) { @@ -205,6 +206,8 @@ private List ParseOptions(string helpText) seenOptions.Add(longForm); + i = AccumulateMultiLineDescription(lines, i, ref description); + var propertyName = NormalizePropertyName(longForm); if (propertyName is null) { @@ -236,6 +239,38 @@ private List ParseOptions(string helpText) return options; } + private static int AccumulateMultiLineDescription( + string[] lines, + int currentIndex, + ref string description) + { + var descriptionParts = new List(); + if (!string.IsNullOrEmpty(description)) + { + descriptionParts.Add(description); + } + + var optionIndent = lines[currentIndex].Length - lines[currentIndex].TrimStart().Length; + var nextIndex = currentIndex + 1; + while (nextIndex < lines.Length) + { + var nextLine = lines[nextIndex]; + var trimmedNext = nextLine.Trim(); + if (string.IsNullOrWhiteSpace(trimmedNext) + || trimmedNext.StartsWith('-') + || nextLine.Length - nextLine.TrimStart().Length <= optionIndent) + { + break; + } + + descriptionParts.Add(trimmedNext); + nextIndex++; + } + + description = string.Join(" ", descriptionParts); + return nextIndex - 1; + } + /// /// Checks if help text indicates the command has options. /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs index adc644b213a..68fb1c55923 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs @@ -436,7 +436,9 @@ private List ParseArguments(string helpText, HashSet Date: Mon, 24 Aug 2026 06:00:00 +0100 Subject: [PATCH 10/15] fix(generator): harden repeatable prose Refs #4006 --- .../Scrapers/RepeatableOptionAdapterTests.cs | 6 +++ .../Scrapers/Cli/CliScraperBase.cs | 47 ++++++++++--------- .../Scrapers/Cli/PackerCliScraper.cs | 3 +- .../Scrapers/Cli/PipCliScraper.cs | 3 +- .../Scrapers/Cli/SnykCliScraper.cs | 5 +- .../Scrapers/Cli/TerraformCliScraper.cs | 4 +- .../Scrapers/Cli/WinGetCliScraper.cs | 9 ++-- 7 files changed, 42 insertions(+), 35 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index ec607ace53d..b3473a0498e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -100,6 +100,12 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() [Arguments("One or more attempts are made before failure.", false)] [Arguments("One or more times may be required.", false)] [Arguments("One or more retries may be attempted.", false)] + [Arguments("Provide one or more attempts before failing.", false)] + [Arguments("Accepts one or more retries.", false)] + [Arguments("Give one or more times to retry.", false)] + [Arguments("Supports multiple values.", true)] + [Arguments("Takes multiple values.", true)] + [Arguments("Value can contain multiple values.", true)] public async Task Packer_Classifies_Explicit_Repeatability_Prose( string description, bool expected) 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 77700d0137c..67a4f9575cf 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -980,6 +980,14 @@ protected static bool IsRepeatableValueOption( bool isBoolean = false) => !isFlag && !isBoolean && DescriptionDeclaresRepeatableOption(description); + /// + /// Converts a scalar C# type into its repeatable collection representation when needed. + /// + protected static string AsCSharpType(string scalarType, bool acceptsMultipleValues) => + acceptsMultipleValues + ? $"IEnumerable<{scalarType.TrimEnd('?')}>?" + : scalarType; + /// /// Parses indentation-based argument declarations into a reusable nested group model. /// The adapter only recognizes one tool-specific declaration line; traversal, @@ -1059,27 +1067,24 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) RegexOptions.IgnoreCase)] private static partial Regex ExplicitBooleanValuePattern(); - [GeneratedRegex( - """ - \b(?: - repeatable - |(?:can|may|must|should)\s+be\s+repeated - |(?:is|are)\s+repeated - |\A(?:one|zero)\s+or\s+more\s+ - (?!(?:attempts?|times?|retries?)\b)[\w-]+ - |(?:can|may|must|should)\s+be\s+ - (?:specified|supplied|provided|used|passed|set|given)\s+ - (?:multiple\s+times|more\s+than\s+once) - |(?:accepts?|specify|supply|provide|use|pass|set|give)\s+ - (?: - multiple\s+times - |more\s+than\s+once - |(?:one|zero)\s+or\s+more\s+[\w-]+ - |multiple\s+[\w-]+ - ) - )\b - """, - RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)] + private const string RepeatableItemCountPattern = + @"(?:one|zero)\s+or\s+more\s+(?!(?:attempts?|times?|retries?)\b)[\w-]+"; + + private const string RepeatableValueRegex = + @"\b(?:" + + @"repeatable" + + @"|(?:can|may|must|should)\s+be\s+repeated" + + @"|(?:is|are)\s+repeated" + + @"|\A" + RepeatableItemCountPattern + + @"|(?:can|may|must|should)\s+be\s+" + + @"(?:specified|supplied|provided|used|passed|set|given)\s+" + + @"(?:multiple\s+times|more\s+than\s+once)" + + @"|(?:accepts?|specify|supply|provide|use|pass|set|give|supports?|takes?|contains?)\s+" + + @"(?:multiple\s+times|more\s+than\s+once|" + + RepeatableItemCountPattern + + @"|multiple\s+[\w-]+))\b"; + + [GeneratedRegex(RepeatableValueRegex, RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)] private static partial Regex RepeatableValuePattern(); [GeneratedRegex(@"\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~])")] diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index acae8f7d88f..40bc1f1a32b 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -216,7 +216,8 @@ private List ParseOptions(string helpText) var isFlag = string.IsNullOrEmpty(valueHint) || valueHint.Contains("true") || valueHint.Contains("false"); var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); - var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); options.Add(new CliOptionDefinition { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs index 5a8f01750f2..0987ad4c5f1 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs @@ -275,7 +275,8 @@ private List ParseOptions(string helpText, string[] command var isFlag = string.IsNullOrEmpty(valueHint) && IsBooleanOption(longForm, description); var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); - var csharpType = acceptsMultipleValues ? "IEnumerable?" : isFlag ? "bool?" : "string?"; + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); options.Add(new CliOptionDefinition { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs index 91c5b26f776..281fa516162 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs @@ -349,10 +349,7 @@ private void ParseOptionLines( } } - if (acceptsMultipleValues) - { - csharpType = $"IEnumerable<{csharpType.TrimEnd('?')}>?"; - } + csharpType = AsCSharpType(csharpType, acceptsMultipleValues); options.Add(new CliOptionDefinition { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index afb88b6bdb4..6fe23534b9e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -341,8 +341,8 @@ private List ParseOptions(string helpText, string[] command private static string GetCSharpType(bool isFlag, bool isInteger, bool acceptsMultipleValues) { - var scalarType = isFlag ? "bool" : isInteger ? "int" : "string"; - return acceptsMultipleValues ? $"IEnumerable<{scalarType}>?" : $"{scalarType}?"; + var scalarType = isFlag ? "bool?" : isInteger ? "int?" : "string?"; + return AsCSharpType(scalarType, acceptsMultipleValues); } /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs index 68fb1c55923..0977c25928a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs @@ -439,12 +439,9 @@ private List ParseArguments(string helpText, HashSet?" - : "string?"; + var acceptsMultipleValues = !isFlag && declaresRepeatability; + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); return new CliOptionDefinition { From c446a513c6c7cfcf400f1d8c72879b6d13986d22 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:12:33 +0100 Subject: [PATCH 11/15] fix(packer): parse quoted option values --- .../Scrapers/RepeatableOptionAdapterTests.cs | 14 ++++++++++++++ .../Scrapers/Cli/PackerCliScraper.cs | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index b3473a0498e..98436986b85 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -56,6 +56,20 @@ public async Task Packer_Recognizes_Repeatable_Prose() await AssertRepeatable(command, "--var-file"); } + [Test] + public async Task Packer_Recognizes_Quoted_Repeatable_Value() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var 'key=value' Set a variable. Can be repeated. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var"); + } + [Test] public async Task Packer_Recognizes_Multiline_Repeatable_Prose() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index 40bc1f1a32b..a7880b7983f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -214,7 +214,9 @@ private List ParseOptions(string helpText) continue; } - var isFlag = string.IsNullOrEmpty(valueHint) || valueHint.Contains("true") || valueHint.Contains("false"); + var isFlag = string.IsNullOrEmpty(valueHint) + || valueHint.Equals("true", StringComparison.OrdinalIgnoreCase) + || valueHint.Equals("false", StringComparison.OrdinalIgnoreCase); var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); var scalarType = isFlag ? "bool?" : "string?"; var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); @@ -306,7 +308,7 @@ protected override bool HasOptions(string helpText) /// -debug Debug mode enabled /// -var 'key=value' Variable for templates /// - [GeneratedRegex(@"^\s+(?-[\w-]+)(?:=(?\S+)|\s+'[^']+')?\s{2,}(?.*)$", RegexOptions.Multiline)] + [GeneratedRegex(@"^\s+(?-[\w-]+)(?:=(?\S+)|\s+'(?[^']+)')?\s{2,}(?.*)$", RegexOptions.Multiline)] private static partial Regex PackerOptionPattern(); #endregion From 9a78e979d4b548f054727d9cf27026bf43707a55 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:18:51 +0100 Subject: [PATCH 12/15] fix(generator): reject qualified retry counts --- .../Scrapers/RepeatableOptionAdapterTests.cs | 4 ++++ .../Scrapers/Cli/CliScraperBase.cs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index 98436986b85..a5986e27e79 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -117,6 +117,10 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() [Arguments("Provide one or more attempts before failing.", false)] [Arguments("Accepts one or more retries.", false)] [Arguments("Give one or more times to retry.", false)] + [Arguments("One or more retry attempts before failing.", false)] + [Arguments("Zero or more retry attempts may be made.", false)] + [Arguments("One or more connection retries before giving up.", false)] + [Arguments("One or more polling attempts before timing out.", false)] [Arguments("Supports multiple values.", true)] [Arguments("Takes multiple values.", true)] [Arguments("Value can contain multiple values.", true)] 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 67a4f9575cf..89db2818c23 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1067,8 +1067,11 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) RegexOptions.IgnoreCase)] private static partial Regex ExplicitBooleanValuePattern(); + private const string OperationalCountPhrasePattern = + @"(?:[\w-]+\s+){0,2}(?:attempts?|times?|retries?)\b"; + private const string RepeatableItemCountPattern = - @"(?:one|zero)\s+or\s+more\s+(?!(?:attempts?|times?|retries?)\b)[\w-]+"; + @"(?:one|zero)\s+or\s+more\s+(?!" + OperationalCountPhrasePattern + @")[\w-]+"; private const string RepeatableValueRegex = @"\b(?:" From 48d1b15c45ff5c8963507335197ee474c3fd00d9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:30:40 +0100 Subject: [PATCH 13/15] fix(generator): align repeatable parser tests --- .../Scrapers/RepeatableOptionAdapterTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index a5986e27e79..c0de5687276 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -222,7 +222,11 @@ private sealed class TestPipCliScraper() NullLogger.Instance) { public Task Parse(string[] commandPath, string helpText) => - ParseCommandAsync(commandPath, helpText, CancellationToken.None); + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); } private sealed class TestPackerCliScraper() @@ -246,6 +250,10 @@ private sealed class TestSnykCliScraper() NullLogger.Instance) { public Task Parse(string[] commandPath, string helpText) => - ParseCommandAsync(commandPath, helpText, CancellationToken.None); + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); } } From 3f5db575110282eba817a92fdf0f36d7e79ddf8d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:51:28 +0100 Subject: [PATCH 14/15] fix(generator): retain boolean value options --- .../Scrapers/Cli/CliScraperTraversalTests.cs | 44 ++++++++++++++++--- .../Scrapers/Cli/CliScraperBase.cs | 2 + 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs index afae3b0b358..9c1be2661fb 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs @@ -687,13 +687,42 @@ fake [flags] { ["--help"] = helpText, }); - var scraper = new ShapeMismatchScraper(executor); + var scraper = new OptionShapeScraper(executor); var commands = await ScrapeAsync(scraper); await Assert.That(commands).IsEmpty(); } + [Test] + public async Task SharedTraversal_Preserves_Boolean_Value_Options_With_Repeatability_Prose() + { + const string helpText = """ + Execute a command. + + Usage: + fake [flags] + + Flags: + --tag= May be specified multiple times + """; + var executor = new StubExecutor(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["--help"] = helpText, + }); + var scraper = new OptionShapeScraper(executor, "bool?"); + + var command = (await ScrapeAsync(scraper)).Single(); + var option = command.Options.Single(); + + using (Assert.Multiple()) + { + await Assert.That(option.CSharpType).IsEqualTo("bool?"); + await Assert.That(option.IsFlag).IsFalse(); + await Assert.That(option.AcceptsMultipleValues).IsFalse(); + } + } + [Test] public async Task SharedTraversal_Propagates_Invalid_Operand_Coverage() { @@ -718,7 +747,7 @@ await Assert.That(Scrape) [Test] public async Task Shared_Skip_Filter_Preserves_Uppercase_Subcommands() { - var scraper = new ShapeMismatchScraper(new StubExecutor( + var scraper = new OptionShapeScraper(new StubExecutor( new Dictionary(StringComparer.OrdinalIgnoreCase))); await Assert.That(scraper.Skips("SSH")).IsFalse(); @@ -899,14 +928,17 @@ public Task IsAvailableAsync( Task.FromResult(true); } - private sealed class ShapeMismatchScraper : CliScraperBase + private sealed class OptionShapeScraper : CliScraperBase { - public ShapeMismatchScraper(ICliCommandExecutor executor) + private readonly string _csharpType; + + public OptionShapeScraper(ICliCommandExecutor executor, string csharpType = "string?") : base( executor, new HelpTextCache(NullLogger.Instance), - NullLogger.Instance) + NullLogger.Instance) { + _csharpType = csharpType; } public override string ToolName => "fake"; @@ -938,7 +970,7 @@ public ShapeMismatchScraper(ICliCommandExecutor executor) { SwitchName = "--tag", PropertyName = "Tag", - CSharpType = "string?", + CSharpType = _csharpType, Description = "May be specified multiple times", }, ], 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 89db2818c23..36e1c99872e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1003,6 +1003,7 @@ private static void ValidateOptionShapes(CliCommandDefinition command, string he foreach (var option in command.Options) { var description = option.Description ?? string.Empty; + var isBoolean = option.CSharpType is "bool" or "bool?"; if (HelpDeclaresExplicitBooleanValue(description) && option.IsFlag) { throw new InvalidOperationException( @@ -1011,6 +1012,7 @@ private static void ValidateOptionShapes(CliCommandDefinition command, string he } if (!option.IsFlag + && !isBoolean && HelpDeclaresRepeatableOption(helpText, option.SwitchName, description) && !option.AcceptsMultipleValues) { From 6ff7908fd8bb573478361f810100a92e57f253b9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:56:08 +0100 Subject: [PATCH 15/15] fix(generator): retain passive repeatability --- .../Scrapers/RepeatableOptionAdapterTests.cs | 3 +++ .../Scrapers/Cli/CliScraperBase.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs index c0de5687276..aeaec591051 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -121,6 +121,9 @@ public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() [Arguments("Zero or more retry attempts may be made.", false)] [Arguments("One or more connection retries before giving up.", false)] [Arguments("One or more polling attempts before timing out.", false)] + [Arguments("This option can be specified one or more times.", true)] + [Arguments("May be provided zero or more times.", true)] + [Arguments("This option should be used more than once.", true)] [Arguments("Supports multiple values.", true)] [Arguments("Takes multiple values.", true)] [Arguments("Value can contain multiple values.", true)] 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 36e1c99872e..57ae62a37a6 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -1083,7 +1083,7 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) + @"|\A" + RepeatableItemCountPattern + @"|(?:can|may|must|should)\s+be\s+" + @"(?:specified|supplied|provided|used|passed|set|given)\s+" - + @"(?:multiple\s+times|more\s+than\s+once)" + + @"(?:(?:one|zero)\s+or\s+more\s+times|multiple\s+times|more\s+than\s+once)" + @"|(?:accepts?|specify|supply|provide|use|pass|set|give|supports?|takes?|contains?)\s+" + @"(?:multiple\s+times|more\s+than\s+once|" + RepeatableItemCountPattern