From d5238555e42a2b0b4a37fb5879aed5330b8b83a5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:12:52 +0100 Subject: [PATCH 1/3] fix(generator): harden command group parsing Keep Cobra section matching on one line and distinguish command operands from genuine command trees.`n`nFixes #3990 --- .../Scrapers/CosignCliScraperTests.cs | 23 +++++++++++++++++++ .../Scrapers/PulumiCliScraperTests.cs | 18 +++++++++++++++ .../Scrapers/SnykCliScraperTests.cs | 20 ++++++++++++---- .../Scrapers/Cli/CliScraperBase.cs | 4 ++-- .../Scrapers/Cli/CobraCliScraper.cs | 2 +- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs index 03b964c17a3..be56b844540 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs @@ -33,6 +33,29 @@ await Assert.That(subcommands).IsEquivalentTo( ["attest", "sign", "verify", "verify-blob-attestation"]); } + [Test] + public async Task Extracts_Cosign_V3_All_Word_Command_Table() + { + const string helpText = """ + Tools for interacting with a Sigstore protobuf bundle + + Usage: + cosign bundle [command] + + Available Commands: + create Create a Sigstore protobuf bundle + inspect Inspect a Sigstore protobuf bundle + upgrade Upgrade a Sigstore protobuf bundle + + Flags: + -h, --help=false: + """; + + var subcommands = new TestCosignCliScraper().Extract(helpText); + + await Assert.That(subcommands).IsEquivalentTo(["create", "inspect", "upgrade"]); + } + [Test] public async Task Parses_Cosign_V3_Default_Value_Flag_Format() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/PulumiCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/PulumiCliScraperTests.cs index c1c5b5798ed..9b9ba843d9f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/PulumiCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/PulumiCliScraperTests.cs @@ -7,6 +7,22 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; public class PulumiCliScraperTests { + [Test] + public async Task Env_Run_Command_Operand_Is_Not_A_Command_Group() + { + const string helpText = """ + Run a command within an environment. + + Usage: + pulumi env run -- [args] + + Run a command + The command receives the environment variables. + """; + + await Assert.That(new TestPulumiCliScraper().DeclaresCommandGroup(helpText)).IsFalse(); + } + [Test] public async Task Env_Get_Preserves_Required_Environment_And_Optional_Path() { @@ -91,5 +107,7 @@ public TestPulumiCliScraper() var usage = ParseUsageSynopsis(commandPath, helpText); return ParseCommandAsync(commandPath, helpText, usage, CancellationToken.None); } + + public bool DeclaresCommandGroup(string helpText) => HelpDeclaresCommandGroup(helpText); } } diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs index 7aa16bba2c6..16a62cda80a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs @@ -56,18 +56,26 @@ public async Task Sbom_Help_Exposes_Test_Subcommand() } [Test] - public async Task Command_Help_Does_Not_Treat_Examples_As_Subcommands() + public async Task Command_Help_Does_Not_Treat_Examples_As_A_Command_Group() { const string helpText = """ - Test a project for vulnerabilities. - + Test Usage snyk test [] - See code test, container test, and iac test for related commands. + Options for build tools + The format is snyk -- [] + + Examples for the snyk test command + $ snyk test """; - await Assert.That(new TestSnykCliScraper().Extract(helpText)).IsEmpty(); + var scraper = new TestSnykCliScraper(); + using (Assert.Multiple()) + { + await Assert.That(scraper.Extract(helpText)).IsEmpty(); + await Assert.That(scraper.DeclaresCommandGroup(helpText)).IsFalse(); + } } [Test] @@ -396,6 +404,8 @@ public TestSnykCliScraper() public IReadOnlyList Extract(string helpText) => ExtractSubcommands(helpText).ToList(); + public bool DeclaresCommandGroup(string helpText) => HelpDeclaresCommandGroup(helpText); + public bool CanGenerate(string helpText) => HasOptions(helpText); public Task Parse(string[] commandPath, string helpText) => 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 c0e7f8171d1..c8994ea9ef3 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -997,11 +997,11 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) protected static partial Regex OptionLinePattern(); [GeneratedRegex( - @"^[ \t]*(?:Usage:?[ \t]*(?:\r?\n[ \t]*)?)?[^\r\n]*(?:|\[command\])[^\r\n]*\r?$", + @"^[ \t]*Usage:?[ \t]*(?:\r?\n[ \t]*)?[^\r\n]*(?:|\[command\])[^\r\n]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex CommandGroupUsagePattern(); - [GeneratedRegex(@"^[ \t]*[A-Z][A-Z0-9 _/-]*COMMANDS?:?[ \t]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + [GeneratedRegex(@"^[ \t]*[A-Z][A-Z0-9 _/-]*COMMANDS:?[ \t]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex CommandSectionHeadingPattern(); [GeneratedRegex( diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs index 0038d2cc372..cb083b249fb 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CobraCliScraper.cs @@ -945,7 +945,7 @@ private static string DetermineCSharpType( /// Matches section headers like "Flags:", "Usage:", etc. /// [GeneratedRegex( - @"^(?:[A-Z][\w\s]*:|[A-Z][\w ]*(?:Commands|Flags|Options|Usage|Examples))\s*$", + @"^(?:[A-Z][\w \t]*:|[A-Z][\w ]*(?:Commands|Flags|Options|Usage|Examples))\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex SectionHeaderPattern(); From 88359ef8ef068c1d2382ef0e76919b354f9ac921 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:42:42 +0100 Subject: [PATCH 2/3] test(cosign): cover command groups --- .../Scrapers/CosignCliScraperTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs index be56b844540..8a63d64d782 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs @@ -56,6 +56,31 @@ upgrade Upgrade a Sigstore protobuf bundle await Assert.That(subcommands).IsEquivalentTo(["create", "inspect", "upgrade"]); } + [Test] + [Arguments("signing-config", "signing config")] + [Arguments("trusted-root", "trusted root")] + public async Task Extracts_Cosign_V3_Single_Row_All_Word_Command_Tables( + string commandGroup, + string description) + { + var helpText = $""" + Tools for interacting with a Sigstore protobuf {description} + + Usage: + cosign {commandGroup} [command] + + Available Commands: + create Create a Sigstore protobuf {description} + + Flags: + -h, --help=false: + """; + + var subcommands = new TestCosignCliScraper().Extract(helpText); + + await Assert.That(subcommands).IsEquivalentTo(["create"]); + } + [Test] public async Task Parses_Cosign_V3_Default_Value_Flag_Format() { From d82e48d1161c4068c778b2e9d3488757cd78ff75 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:34:33 +0100 Subject: [PATCH 3/3] fix(generator): parse multiline group usage Recognize a command placeholder on the second synopsis line while keeping detection bounded to the Usage block. Pin the genuine empty-group validation failure. Refs #3990. --- .../Scrapers/Cli/CliScraperTraversalTests.cs | 50 +++++++++++++++++-- .../Scrapers/Cli/CliScraperBase.cs | 2 +- 2 files changed, 48 insertions(+), 4 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 bdaef2fccce..1d20f00d562 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 @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModularPipelines.Attributes; using ModularPipelines.OptionsGenerator.Models; @@ -90,7 +91,8 @@ fake sibling [flags] --value string Supply a value """, }); - var scraper = new TestCobraScraper(executor); + var logger = new RecordingLogger(); + var scraper = new TestCobraScraper(executor, logger); await Assert.That(scraper.DeclaresCommandGroup(emptyGroupHelp)).IsTrue(); await Assert.That(scraper.GetSubcommands(emptyGroupHelp)).IsEmpty(); @@ -101,6 +103,25 @@ await Assert.That(commands.Select(command => command.FullCommand)) .IsEquivalentTo(["fake sibling"]); await Assert.That(executor.Arguments) .IsEquivalentTo(["--help", "parent --help", "sibling --help"]); + await Assert.That(logger.Warnings).Contains(warning => + warning.Exception is InvalidOperationException + && warning.Message.Contains("Failed to validate subcommand discovery: fake parent")); + } + + [Test] + public async Task SharedTraversal_Detects_Command_On_Second_Usage_Line() + { + const string helpText = """ + Usage: + fake [flags] + fake [flags] + + Available Commands: + """; + var scraper = new TestCobraScraper(new StubExecutor( + new Dictionary(StringComparer.OrdinalIgnoreCase))); + + await Assert.That(scraper.DeclaresCommandGroup(helpText)).IsTrue(); } [Test] @@ -409,11 +430,11 @@ private static async Task> ScrapeAsync(ICliS private sealed class TestCobraScraper : CobraCliScraper { - public TestCobraScraper(ICliCommandExecutor executor) + public TestCobraScraper(ICliCommandExecutor executor, ILogger? logger = null) : base( executor, new HelpTextCache(NullLogger.Instance), - NullLogger.Instance) + logger ?? NullLogger.Instance) { } @@ -438,6 +459,29 @@ public TestCobraScraper(ICliCommandExecutor executor) public IReadOnlyList GetSubcommands(string helpText) => ExtractSubcommands(helpText).ToList(); } + private sealed class RecordingLogger : ILogger + { + public List<(string Message, Exception? Exception)> Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add((formatter(state, exception), exception)); + } + } + } + private sealed class TestPodmanCliScraper(ICliCommandExecutor executor) : PodmanCliScraper( executor, 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 c8994ea9ef3..cee253687e7 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -997,7 +997,7 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) protected static partial Regex OptionLinePattern(); [GeneratedRegex( - @"^[ \t]*Usage:?[ \t]*(?:\r?\n[ \t]*)?[^\r\n]*(?:|\[command\])[^\r\n]*\r?$", + @"^[ \t]*Usage:?[ \t]*(?:[^\r\n]*\r?\n[ \t]*){0,2}[^\r\n]*(?:|\[command\])[^\r\n]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex CommandGroupUsagePattern();