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.Tests/Scrapers/CosignCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CosignCliScraperTests.cs index 03b964c17a3..8a63d64d782 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,54 @@ 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] + [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() { 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..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,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]*\r?\n[ \t]*){0,2}[^\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();