From 0fac901eb991c58d42c53cfff032977fd973942d Mon Sep 17 00:00:00 2001 From: autocarl Date: Fri, 24 Jul 2026 20:45:53 -0400 Subject: [PATCH 01/61] feat: add fluent hidden options --- docs/commands.md | 36 ++++++++ docs/parameter-system.md | 6 ++ .../Autocomplete/AutocompleteEngine.cs | 5 + src/Repl.Core/CommandBuilder.cs | 91 +++++++++++++++++++ src/Repl.Core/CoreReplApp.cs | 11 +++ .../Documentation/DocumentationEngine.cs | 5 +- .../Help/HelpTextBuilder.Rendering.cs | 5 +- .../Options/OptionTokenCompletionSource.cs | 29 +++++- src/Repl.Core/OptionBuilder.cs | 25 +++++ .../Attributes/ReplOptionAttribute.cs | 6 ++ .../Parsing/GlobalOptionDefinition.cs | 3 +- src/Repl.Core/ParsingOptions.cs | 42 ++++++++- src/Repl.Core/Session/InteractiveSession.cs | 3 +- .../ShellCompletion/ShellCompletionEngine.cs | 5 +- src/Repl.Defaults/GlobalOptionsExtensions.cs | 9 +- .../Given_Completions.cs | 63 +++++++++++++ .../Given_CustomGlobalOptions.cs | 30 ++++++ .../Given_GlobalOptionsAccessor.cs | 71 +++++++++++++++ .../Given_HelpDiscovery.cs | 58 ++++++++++++ .../Given_OptionsGroupBinding.cs | 43 +++++++++ src/Repl.Mcp/McpServerHandler.cs | 33 ++++--- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 76 ++++++++++++++++ .../Given_CommandBuilderEnrichment.cs | 45 +++++++++ src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 15 +++ ...nteractiveAutocomplete_OptionCandidates.cs | 55 +++++++++++ 25 files changed, 746 insertions(+), 24 deletions(-) create mode 100644 src/Repl.Core/OptionBuilder.cs diff --git a/docs/commands.md b/docs/commands.md index caf23729..079d47b5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -58,6 +58,42 @@ app.Map( Root help now includes a dedicated `Global Options:` section with built-ins plus custom options registered through `options.Parsing.AddGlobalOption(...)`. +### Hiding individual options + +Use `.Option().Hidden()` to hide one command option without hiding its command. The target is the CLR handler parameter or options-group property name, not the rendered `--option-name` token: + +```csharp +app.Map( + "deploy", + ([ReplOption(Name = "internal-mode")] bool internalMode = false) => internalMode) + .Option("internalMode") + .Hidden(); +``` + +For declarative registration, set `Hidden = true` on `ReplOptionAttribute`. This works on direct parameters, options-group properties, and typed global-options properties: + +```csharp +public sealed class DeploymentOptions +{ + [ReplOption(Hidden = true)] + public string? InternalToken { get; set; } +} +``` + +Manually registered global options can be selected after registration: + +```csharp +app.Options(options => +{ + options.Parsing.AddGlobalOption("internal-tenant"); + options.Parsing.GlobalOption("internal-tenant").Hidden(); +}); +``` + +Hidden options are omitted from help, interactive and shell completion (including value providers), documentation export, and generated MCP schemas. They remain valid parser inputs and bind normally when supplied explicitly. + +A hidden option must be optional. Hiding an option whose effective arity is `ExactlyOne` or `OneOrMore` causes configuration validation to fail before execution or MCP initialization, because discovery clients would otherwise have no valid invocation path. A fluent `.Hidden(isHidden: false)` call overrides `ReplOptionAttribute.Hidden` and can re-expose an attribute-hidden required option before validation runs. + ### Accessing global options outside handlers Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers: diff --git a/docs/parameter-system.md b/docs/parameter-system.md index 472d90b5..39bd588e 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -26,6 +26,7 @@ Application-facing parameter DSL: - explicit `Aliases` (full tokens, for example `-m`, `--mode`) - explicit `ReverseAliases` (for example `--no-verbose`) - `Mode` (`OptionOnly`, `ArgumentOnly`, `OptionAndPositional`) + - `Hidden` to suppress discovery without changing parsing or binding - optional per-parameter `CaseSensitivity` - optional `Arity` - `ReplArgumentAttribute` @@ -41,6 +42,8 @@ Supporting enums: - `ReplParameterMode` - `ReplArity` +Option visibility can also be configured fluently with `CommandBuilder.Option(targetName).Hidden()` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be optional: effective arity `ExactlyOne` or `OneOrMore` is rejected before execution and discovery-model generation. + ### Options groups - `ReplOptionsGroupAttribute` (on a class) marks it as a reusable parameter group @@ -107,6 +110,9 @@ This same schema drives: - command help option sections - shell option completion candidates - exported documentation option metadata +- generated MCP tool and prompt schemas + +Hidden options are filtered from those discovery surfaces, while the same schema continues to accept and bind their tokens during direct execution. ## System.CommandLine comparison diff --git a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs index fe6730b9..526ba0bf 100644 --- a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs +++ b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs @@ -1592,6 +1592,11 @@ internal static bool IsControlFreeValue(string value) => pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity); foreach (var entry in entries) { + if (match.Route.Command.IsOptionHidden(entry.ParameterName)) + { + continue; + } + // Same keystroke rule as the positional path: providers only run for an explicit // completion request; live-hint refreshes fall through to the static enum fallback. if (providersAllowed diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 724dc4fb..0402e5d2 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -1,3 +1,7 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Repl.Internal.Options; + namespace Repl; /// @@ -11,6 +15,15 @@ public sealed class CommandBuilder private readonly Dictionary _completionScopes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _hiddenOptions = + new(StringComparer.OrdinalIgnoreCase); + + private readonly HashSet _attributeHiddenOptions = + new(StringComparer.OrdinalIgnoreCase); + + private HashSet? _knownOptionTargets; + private HashSet? _requiredOptionTargets; + /// /// Initializes a new instance of the class. /// @@ -20,6 +33,7 @@ internal CommandBuilder(string route, Delegate handler) { Route = route; Handler = handler; + CollectAttributeHiddenOptions(handler, _attributeHiddenOptions); SupportsHostedProtocolPassthrough = ComputeSupportsHostedProtocolPassthrough(handler); } @@ -139,6 +153,83 @@ public CommandBuilder WithAlias(params string[] aliases) return this; } + /// + /// Selects a handler parameter or options-group property for option metadata configuration. + /// + /// Handler parameter or options-group property name. + /// An option metadata builder. + public OptionBuilder Option(string targetName) + { + targetName = string.IsNullOrWhiteSpace(targetName) + ? throw new ArgumentException("Option target name cannot be empty.", nameof(targetName)) + : targetName; + if (_knownOptionTargets is null || !_knownOptionTargets.Contains(targetName)) + { + throw new KeyNotFoundException( + $"No option target named '{targetName}' is registered for command '{Route}'."); + } + + return new OptionBuilder(isHidden => _hiddenOptions[targetName] = isHidden); + } + + internal void SetOptionTargets(OptionSchema schema) + { + _knownOptionTargets = schema.Parameters.Values + .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) + .Select(parameter => parameter.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + _requiredOptionTargets = schema.Entries + .Where(entry => + _knownOptionTargets.Contains(entry.ParameterName) + && entry.Arity is ReplArity.ExactlyOne or ReplArity.OneOrMore) + .Select(entry => entry.ParameterName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + internal void ValidateOptionMetadata() + { + var invalidTarget = _requiredOptionTargets?.FirstOrDefault(IsOptionHidden); + if (invalidTarget is not null) + { + throw new InvalidOperationException( + $"Option target '{invalidTarget}' for command '{Route}' cannot be hidden because it is required."); + } + } + + internal bool IsOptionHidden(string targetName) => + _hiddenOptions.TryGetValue(targetName, out var isHidden) + ? isHidden + : _attributeHiddenOptions.Contains(targetName); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2075", + Justification = "Options group types are user-defined and preserved by the handler delegate reference.")] + private static void CollectAttributeHiddenOptions(Delegate handler, HashSet hiddenOptions) + { + foreach (var parameter in handler.Method.GetParameters()) + { + if (!string.IsNullOrWhiteSpace(parameter.Name) + && (parameter.GetCustomAttribute(inherit: true)?.Hidden ?? false)) + { + hiddenOptions.Add(parameter.Name); + } + + if (!Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + { + continue; + } + + foreach (var property in parameter.ParameterType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute(inherit: true)?.Hidden ?? false) + { + hiddenOptions.Add(property.Name); + } + } + } + } + /// /// Adds a completion provider for a target parameter, invoked by in-process surfaces only /// (the interactive Tab menu and the complete ambient command). diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index bfc75d18..0c5f3380 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -181,6 +181,7 @@ public CommandBuilder Map(string route, Delegate handler) _commands.Add(command); var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing, _implicitServiceParameters); + command.SetOptionTargets(optionSchema); var routeDefinition = new RouteDefinition(template, command, moduleId, optionSchema); _routes.Add(routeDefinition); InvalidateRouting(); @@ -442,8 +443,18 @@ private ReplRuntimeChannel ResolveCurrentRuntimeChannel() // could later read a completion-time module-absent graph and fail as "Unknown command". // Completion passes useDurableCache: false: it neither reads nor writes the shared cache, // computing a fresh graph scoped to this pass. + internal void ValidateOptionMetadata() + { + foreach (var command in _commands) + { + command.ValidateOptionMetadata(); + } + } + internal ActiveRoutingGraph ResolveActiveRoutingGraph(bool useDurableCache) { + ValidateOptionMetadata(); + var runtime = _runtimeState.Value; var serviceProvider = runtime?.ServiceProvider ?? _services; var channel = ResolveCurrentRuntimeChannel(); diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 8c77d2e2..76d53f18 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -216,7 +216,8 @@ private ReplDocOption[] BuildDocumentationOptions( && !app.ImplicitServiceParameters.IsImplicitServiceParameter(parameter.ParameterType) && parameter.GetCustomAttribute() is null && parameter.GetCustomAttribute() is null - && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true) + && !route.Command.IsOptionHidden(parameter.Name!)) .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)); var groupOptions = handlerParams .Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) @@ -224,7 +225,7 @@ private ReplDocOption[] BuildDocumentationOptions( { var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType); return GetOptionsGroupProperties(parameter.ParameterType) - .Where(prop => prop.CanWrite) + .Where(prop => prop.CanWrite && !route.Command.IsOptionHidden(prop.Name)) .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); }); return regularOptions.Concat(groupOptions).ToArray(); diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index caa47fdf..bd10bd3e 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -282,7 +282,9 @@ private static HelpRenderEntry[] BuildOptionRows(RouteDefinition route) } return route.OptionSchema.Parameters.Values - .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) + .Where(parameter => + parameter.Mode != ReplParameterMode.ArgumentOnly + && !route.Command.IsOptionHidden(parameter.Name)) .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters, groupProperties)) .Where(row => row is not null) .Select(row => new HelpRenderEntry(row![0], row[1])) @@ -591,6 +593,7 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) { ArgumentNullException.ThrowIfNull(parsingOptions); var customRows = parsingOptions.GlobalOptions.Values + .Where(option => !option.IsHidden) .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) .Select(option => { diff --git a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs index e703c159..8a797f8f 100644 --- a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs +++ b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs @@ -74,6 +74,11 @@ internal static void CollectGlobalOptionTokens( foreach (var custom in options.Parsing.GlobalOptions.Values) { + if (custom.IsHidden) + { + continue; + } + TryAdd(custom.CanonicalToken, currentTokenPrefix, comparison, dedupe, results); foreach (var alias in custom.Aliases) @@ -90,7 +95,13 @@ internal static void CollectRouteOptionTokens( ReplCaseSensitivity globalCaseSensitivity, HashSet dedupe, List results) => - CollectRouteOptionTokens(route.OptionSchema, currentTokenPrefix, globalCaseSensitivity, dedupe, results); + CollectRouteOptionTokens( + route.OptionSchema, + currentTokenPrefix, + globalCaseSensitivity, + dedupe, + results, + entry => !route.Command.IsOptionHidden(entry.ParameterName)); // Filters against the schema entries — not the flattened KnownTokens — so each option's // own case sensitivity is honored: an entry declared case-insensitive is offered for a @@ -101,10 +112,24 @@ internal static void CollectRouteOptionTokens( string currentTokenPrefix, ReplCaseSensitivity globalCaseSensitivity, HashSet dedupe, - List results) + List results) => + CollectRouteOptionTokens(schema, currentTokenPrefix, globalCaseSensitivity, dedupe, results, include: null); + + private static void CollectRouteOptionTokens( + OptionSchema schema, + string currentTokenPrefix, + ReplCaseSensitivity globalCaseSensitivity, + HashSet dedupe, + List results, + Func? include) { foreach (var entry in schema.Entries) { + if (include is not null && !include(entry)) + { + continue; + } + var comparison = (entry.CaseSensitivity ?? globalCaseSensitivity) == ReplCaseSensitivity.CaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; diff --git a/src/Repl.Core/OptionBuilder.cs b/src/Repl.Core/OptionBuilder.cs new file mode 100644 index 00000000..e41fc8d2 --- /dev/null +++ b/src/Repl.Core/OptionBuilder.cs @@ -0,0 +1,25 @@ +namespace Repl; + +/// +/// Configures discovery metadata for an option. +/// +public sealed class OptionBuilder +{ + private readonly Action _setHidden; + + internal OptionBuilder(Action setHidden) + { + _setHidden = setHidden; + } + + /// + /// Hides or shows the option on discovery surfaces without changing parsing or binding. + /// + /// Whether the option is hidden. + /// The same builder instance. + public OptionBuilder Hidden(bool isHidden = true) + { + _setHidden(isHidden); + return this; + } +} diff --git a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs index 96858fab..fd5dde86 100644 --- a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs +++ b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs @@ -26,6 +26,12 @@ public sealed class ReplOptionAttribute : Attribute /// public ReplParameterMode Mode { get; set; } = ReplParameterMode.OptionAndPositional; + /// + /// Gets or sets a value indicating whether the option is hidden from discovery surfaces. + /// Parsing and binding are unaffected. + /// + public bool Hidden { get; set; } + // Nullable enums are not legal attribute named arguments (CS0655), so the optional // overrides expose a non-nullable property and track the unset state in a nullable // backing field surfaced through the read-only *Override properties. diff --git a/src/Repl.Core/Parsing/GlobalOptionDefinition.cs b/src/Repl.Core/Parsing/GlobalOptionDefinition.cs index 6fd93bc0..960e096d 100644 --- a/src/Repl.Core/Parsing/GlobalOptionDefinition.cs +++ b/src/Repl.Core/Parsing/GlobalOptionDefinition.cs @@ -7,4 +7,5 @@ internal sealed record GlobalOptionDefinition( string? DefaultValue, string? Description, Type ValueType, - Type? OwnerType); + Type? OwnerType, + bool IsHidden); diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 1b4084b0..b6946d87 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -156,7 +156,44 @@ public void AddGlobalOption(string name, string constraintOrTypeName, string[]? public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases, string? defaultValue, string description) => AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue, description); - internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, string? description = null, Type? ownerType = null) + /// + /// Selects a registered global option for discovery metadata configuration. + /// + /// Canonical option name, with or without the -- prefix. + /// An option metadata builder. + /// No global option with the supplied canonical name is registered. + public OptionBuilder GlobalOption(string name) + { + name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) + : name.Trim(); + var canonicalToken = NormalizeLongToken(name); + var definition = _globalOptions.Values.FirstOrDefault(option => + string.Equals(option.Name, name, StringComparison.OrdinalIgnoreCase) + || string.Equals(option.CanonicalToken, canonicalToken, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException($"No global option named '{name}' is registered."); + + return new OptionBuilder(isHidden => + _globalOptions[definition.Name] = definition with { IsHidden = isHidden }); + } + + internal void AddGlobalOptionCore( + string name, + Type valueType, + string[]? aliases, + string? defaultValue, + string? description = null, + Type? ownerType = null) => + AddGlobalOptionCore(name, valueType, aliases, defaultValue, description, ownerType, isHidden: false); + + internal void AddGlobalOptionCore( + string name, + Type valueType, + string[]? aliases, + string? defaultValue, + string? description, + Type? ownerType, + bool isHidden) { name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) @@ -182,7 +219,8 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases DefaultValue: defaultValue, Description: description, ValueType: valueType, - OwnerType: ownerType); + OwnerType: ownerType, + IsHidden: isHidden); } private static string BuildDuplicateGlobalOptionMessage(string name, Type? existingOwner, Type? newOwner) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 88de4bfa..2ecd5ca1 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -733,7 +733,8 @@ internal async ValueTask HandleCompletionAmbientCommandAsync( return false; } - if (!match.Route.Command.Completions.TryGetValue(target, out var completion)) + if (match.Route.Command.IsOptionHidden(target) + || !match.Route.Command.Completions.TryGetValue(target, out var completion)) { await ReplSessionIO.Output.WriteLineAsync($"Error: no completion provider registered for '{target}'.").ConfigureAwait(false); return false; diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs index 41c9e547..07a08f3a 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -424,7 +424,7 @@ private async ValueTask TryAddPendingOptionProviderCandidatesAsync( // A quoted value context is unsafe for provider output (see the positional path); // skipping here lets the static enum fallback still run. if (PrefixHasQuoteContext(currentTokenPrefix) - || !TryResolvePendingRouteOption(match, out var entry) + || !TryResolvePendingRouteOption(match, out var entry) || match.Route.Command.IsOptionHidden(entry.ParameterName) || !match.Route.Command.Completions.TryGetValue(entry.ParameterName, out var completion) || !match.Route.Command.IsCompletionShellScoped(entry.ParameterName)) { @@ -589,7 +589,8 @@ private bool TryAddRouteEnumValueCandidates( HashSet dedupe, List candidates) { - if (!TryResolvePendingRouteOption(match, out var entry)) + if (!TryResolvePendingRouteOption(match, out var entry) + || match.Route.Command.IsOptionHidden(entry.ParameterName)) { return false; } diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 34765b7d..d3ce8ec2 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -44,7 +44,14 @@ public static class GlobalOptionsExtensions var defaultValue = property.GetValue(prototype)?.ToString(); var description = property.GetCustomAttribute()?.Description; - options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T)); + options.Parsing.AddGlobalOptionCore( + name, + property.PropertyType, + aliases, + defaultValue, + description, + typeof(T), + isHidden: optionAttr?.Hidden ?? false); } }); diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index e3f20228..e4a15f59 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -25,6 +25,28 @@ public void When_InteractiveCompleteCommandIsUsed_Then_CompletionProviderCandida output.Text.Should().Contain("ab002"); } + [TestMethod] + [Description("The interactive complete ambient command cannot invoke a completion provider belonging to a hidden option.")] + public void When_InteractiveCompleteTargetsHiddenOption_Then_ProviderIsNotInvoked() + { + var sut = ReplApp.Create().UseDefaultInteractive(); + var command = sut.Map( + "deploy", + static string ([ReplOption(Name = "secret-mode")] string? secretMode = null) => secretMode ?? "none"); + command.WithCompletion( + "secretMode", + static (_, _, _) => ValueTask.FromResult>(["internal-debug"])); + command.Option("secretMode").Hidden(); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "complete deploy --target secretMode\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Error: no completion provider registered for 'secretMode'."); + output.Text.Should().NotContain("internal-debug"); + } + [TestMethod] [Description("Regression guard: verifies interactive complete command uses unknown target so that error is rendered.")] public void When_InteractiveCompleteCommandUsesUnknownTarget_Then_ErrorIsRendered() @@ -88,6 +110,47 @@ public void When_AutocompleteModeCommandIsUsed_Then_SessionOverrideIsApplied() output.Text.Should().Contain("override=Off"); } + [TestMethod] + [Description("Hidden custom global options and their aliases are omitted from shell completion while visible framework options remain discoverable.")] + public void When_CompletingGlobalPrefixWithHiddenCustomOption_Then_HiddenTokensAreNotSuggested() + { + var sut = ReplApp.Create() + .Options(options => + { + options.Parsing.AddGlobalOption("region", aliases: ["-r"]); + options.Parsing.AddGlobalOption("tenant", aliases: ["-t"]); + options.Parsing.GlobalOption("tenant").Hidden(); + }); + sut.Map("ping", () => "pong"); + + var longCandidates = Complete("repl --"); + var shortCandidates = Complete("repl -"); + + longCandidates.Should().Contain("--help"); + longCandidates.Should().NotContain("--tenant"); + shortCandidates.Should().Contain("-r"); + shortCandidates.Should().NotContain("-t"); + + string[] Complete(string line) + { + var output = ConsoleCaptureHelper.Capture(() => sut.Run( + [ + "completion", + "__complete", + "--shell", + "bash", + "--line", + line, + "--cursor", + line.Length.ToString(CultureInfo.InvariantCulture), + "--no-logo", + ])); + + output.ExitCode.Should().Be(0); + return output.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries); + } + } + [TestMethod] [Description("Regression guard: verifies shell completion suggests custom global options so app-registered globals remain discoverable from tab completion.")] public void When_CompletingGlobalPrefix_Then_CustomGlobalOptionsAreSuggested() diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index 77e39ae4..d58e3fa6 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -50,6 +50,36 @@ public void When_RequestingRootHelp_Then_CustomGlobalOptionIsListedInGlobalOptio output.Text.Should().Contain("Custom global option."); } + [TestMethod] + [Description("Hidden(false) re-exposes a manually registered global option after it was hidden.")] + public void When_HiddenGlobalOptionIsShownAgain_Then_RootHelpListsIt() + { + var sut = ReplApp.Create() + .Options(options => + { + options.Parsing.AddGlobalOption("tenant"); + options.Parsing.GlobalOption("tenant").Hidden().Hidden(isHidden: false); + }); + sut.Map("ping", () => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("--tenant"); + } + + [TestMethod] + [Description("Selecting an unregistered global option fails clearly instead of silently creating ineffective metadata.")] + public void When_SelectingUnknownGlobalOption_Then_ConfigurationThrows() + { + var parsing = new ParsingOptions(); + + var act = () => parsing.GlobalOption("missing"); + + act.Should().Throw() + .WithMessage("*missing*registered*"); + } + [TestMethod] [Description("Regression guard: verifies typed global option descriptions are rendered in root help without adding default-value display.")] public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAreListed() diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 05cca86d..e89cd702 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -22,6 +22,35 @@ public void When_GlobalOptionProvided_Then_HandlerCanReadItViaAccessor() output.Text.Should().Contain("acme"); } + [TestMethod] + [Description("A manually registered hidden global option is omitted from help while remaining available through the accessor when explicitly provided.")] + public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() + { + var sut = ReplApp.Create(); + sut.Options(options => + { + options.Parsing.AddGlobalOption( + "tenant", + aliases: ["-t"], + defaultValue: "internal-default", + description: "Internal tenant selector."); + options.Parsing.GlobalOption("tenant").Hidden(); + }); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("tenant") ?? "none"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => + sut.Run(["show", "--tenant", "acme", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().NotContain("--tenant"); + help.Text.Should().NotContain("-t"); + help.Text.Should().NotContain("Internal tenant selector."); + help.Text.Should().NotContain("internal-default"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("acme"); + } + [TestMethod] [Description("Global option is accessible in middleware via DI.")] public void When_GlobalOptionProvided_Then_MiddlewareCanReadIt() @@ -143,6 +172,40 @@ public void When_UsingTypedGlobalOptions_Then_ClassIsPopulatedFromParsedValues() output.Text.Should().Contain("acme:9090"); } + [TestMethod] + [Description("ReplOption.Hidden on a typed global-options property hides discovery while preserving typed injection and binding.")] + public void When_TypedGlobalOptionPropertyIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (HiddenGlobalOptions options) => $"{options.Region}:{options.InternalToken}"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => sut.Run( + ["show", "--region", "east", "--internal-token", "secret", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--region"); + help.Text.Should().NotContain("--internal-token"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("east:secret"); + } + + [TestMethod] + [Description("A fluent Hidden(false) override re-exposes a typed global option hidden by attribute.")] + public void When_TypedGlobalOptionHiddenAttributeIsOverriddenWithFalse_Then_RootHelpListsIt() + { + var sut = ReplApp.Create() + .UseGlobalOptions() + .Options(options => options.Parsing.GlobalOption("internal-token").Hidden(isHidden: false)); + sut.Map("show", (HiddenGlobalOptions globals) => globals.InternalToken ?? "none"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--internal-token"); + } + [TestMethod] [Description("UseGlobalOptions handler parameters are injected from DI even when the parameter name matches a global option.")] public void When_TypedGlobalOptionsParameterNameMatchesGlobalOption_Then_HandlerReceivesDiInstance() @@ -485,6 +548,14 @@ private sealed class ClrDefaultGlobals public int Retries { get; set; } } + private sealed class HiddenGlobalOptions + { + public string? Region { get; set; } + + [ReplOption(Hidden = true)] + public string? InternalToken { get; set; } + } + private interface IInterfaceGlobalOptions { string? Tenant { get; } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 5733fe40..0f3b90af 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -83,6 +83,64 @@ public void When_RequestingCommandHelp_Then_UsageAndDescriptionAreRendered() output.Text.Should().Contain("Description: List contacts"); } + [TestMethod] + [Description("A hidden command option stays bindable when explicitly provided but is omitted from command help.")] + public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() + { + var sut = ReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internal-mode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => + sut.Run(["deploy", "--environment", "prod", "--internal-mode", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--environment"); + help.Text.Should().NotContain("--internal-mode"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("prod:True"); + } + + [TestMethod] + [Description("A required option cannot be hidden because discovery consumers such as MCP would have no valid invocation path.")] + public void When_RequiredCommandOptionIsHidden_Then_ConfigurationFailsBeforeExecution() + { + var sut = ReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) + .Option("internalToken") + .Hidden(); + + var act = () => sut.Run(["deploy", "--internal-token", "secret", "--no-logo"]); + + act.Should().Throw() + .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); + } + + [TestMethod] + [Description("A fluent Hidden(false) override can re-expose a required option hidden by attribute before configuration validation runs.")] + public void When_RequiredOptionHiddenAttributeIsOverriddenWithFalse_Then_ExplicitInvocationSucceeds() + { + var sut = ReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne, Hidden = true)] string internalToken) => internalToken) + .Option("internalToken") + .Hidden(isHidden: false); + + var invocation = ConsoleCaptureHelper.Capture(() => + sut.Run(["deploy", "--internal-token", "secret", "--no-logo"])); + + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("secret"); + } + [TestMethod] [Description("Regression guard: verifies requesting command help for aliased command so that aliases are shown.")] public void When_RequestingCommandHelpForAliasedCommand_Then_AliasesAreShown() diff --git a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs index d0f1ff38..f3b4466a 100644 --- a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -15,6 +15,16 @@ public class TestOutputOptions public bool Verbose { get; set; } } + [ReplOptionsGroup] + public class HiddenOutputOptions + { + [ReplOption] + public string Format { get; set; } = "text"; + + [ReplOption(Name = "internal-token", Hidden = true)] + public string? InternalToken { get; set; } + } + [ReplOptionsGroup] public class TestPagingOptions { @@ -138,6 +148,39 @@ public void When_UsingTwoGroups_Then_BothBindIndependently() output.Text.Should().Contain("json:20:5"); } + [TestMethod] + [Description("A hidden options-group property is omitted from help while remaining bindable when explicitly provided.")] + public void When_OptionsGroupPropertyIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() + { + var sut = ReplApp.Create(); + sut.Map("list", (HiddenOutputOptions options) => $"{options.Format}:{options.InternalToken}"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => sut.Run( + ["list", "--format", "json", "--internal-token", "secret", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--format"); + help.Text.Should().NotContain("--internal-token"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("json:secret"); + } + + [TestMethod] + [Description("A fluent Hidden(false) override re-exposes an options-group property hidden by attribute.")] + public void When_HiddenAttributeIsOverriddenFluentlyWithFalse_Then_OptionIsVisibleAgain() + { + var sut = ReplApp.Create(); + sut.Map("list", (HiddenOutputOptions options) => "ok") + .Option(nameof(HiddenOutputOptions.InternalToken)) + .Hidden(isHidden: false); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--help", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--internal-token"); + } + [TestMethod] [Description("Regression guard: a nullable group property initialized to the CLR default of its underlying type (int? = 0, bool? = false) is a deliberate default the binder preserves, so command help must advertise it — while implicit defaults of non-nullable properties stay hidden.")] public void When_NullableGroupPropertyInitializedToUnderlyingClrDefault_Then_HelpShowsDefault() diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2ff84ebe..3bd78839 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -73,32 +73,41 @@ public McpServerHandler( "Trimming", "IL2026", Justification = "MCP server handler runs in a context where all types are preserved.")] - public async Task RunAsync(IReplIoContext io, CancellationToken ct) + public Task RunAsync(IReplIoContext io, CancellationToken ct) { + var coreApp = _app as CoreReplApp + ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); + coreApp.ValidateOptionMetadata(); + var serverOptions = BuildDynamicServerOptions(); var serverName = serverOptions.ServerInfo?.Name ?? "repl-mcp-server"; var transport = _options.TransportFactory is { } factory ? factory(serverName, io) : new StdioServerTransport(serverName); - try - { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); + return RunCoreAsync(); + async Task RunCoreAsync() + { try { - await server.RunAsync(ct).ConfigureAwait(false); + var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); + AttachServer(server); + + try + { + await server.RunAsync(ct).ConfigureAwait(false); + } + finally + { + UnsubscribeFromRoutingChanges(); + await server.DisposeAsync().ConfigureAwait(false); + } } finally { - UnsubscribeFromRoutingChanges(); - await server.DisposeAsync().ConfigureAwait(false); + await transport.DisposeAsync().ConfigureAwait(false); } } - finally - { - await transport.DisposeAsync().ConfigureAwait(false); - } } internal McpServerOptions BuildDynamicServerOptions() diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 8df6fb42..cc6186e3 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -508,6 +508,82 @@ public async Task When_PromptReturnsString_Then_TextIsPlainString() text.Should().Be("Investigate the checkout service for this symptom: 'queue depth rising'. Start with ops_status, inspect failed checks, then propose the smallest safe next step."); } + [TestMethod] + [Description("Hidden command options are omitted from MCP tool schemas while visible sibling options remain discoverable.")] + public async Task When_CommandOptionIsHidden_Then_McpToolSchemaOmitsIt() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + }); + + var tools = await fixture.Client.ListToolsAsync(); + var tool = tools.Single(candidate => string.Equals(candidate.Name, "deploy", StringComparison.Ordinal)); + var properties = tool.JsonSchema.GetProperty("properties"); + + properties.TryGetProperty("environment", out _).Should().BeTrue(); + properties.TryGetProperty("internalMode", out _).Should().BeFalse(); + } + + [TestMethod] + [Description("MCP initialization validates required hidden options even when explicit server and Apps settings bypass documentation discovery.")] + public async Task When_RequiredCommandOptionIsHiddenAndAppsAreEnabled_Then_McpInitializationFailsClearly() + { + Func act = async () => + { + var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) + .Option("internalToken") + .Hidden(); + }, + configureOptions: options => + { + options.ServerName = "issue-76-test"; + options.EnableApps = true; + }).ConfigureAwait(false); + await fixture.DisposeAsync().ConfigureAwait(false); + }; + + var exception = await act.Should().ThrowAsync().ConfigureAwait(false); + exception.WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); + } + + [TestMethod] + [Description("MCP initialization validates required hidden options even when explicit server and UI resource settings bypass documentation discovery.")] + public async Task When_RequiredCommandOptionIsHiddenAndUiResourceIsRegistered_Then_McpInitializationFailsClearly() + { + Func act = async () => + { + var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) + .Option("internalToken") + .Hidden(); + }, + configureOptions: options => + { + options.ServerName = "issue-76-test"; + options.UiResource("ui://issue-76/test", "Test"); + }).ConfigureAwait(false); + await fixture.DisposeAsync().ConfigureAwait(false); + }; + + var exception = await act.Should().ThrowAsync().ConfigureAwait(false); + exception.WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); + } + // ── Options group camelCase naming ───────────────────────────────── [TestMethod] diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index 5d03633a..395285b6 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -223,6 +223,51 @@ public void When_HandlerHasDescriptionAttribute_Then_ArgumentDescriptionIsPopula arg.Description.Should().Be("Contact numeric id"); } + [TestMethod] + [Description("Verifies a command option hidden through the fluent option builder is omitted from discovery while sibling options remain visible.")] + public void When_CommandOptionIsHiddenFluently_Then_DocumentationOmitsOnlyThatOption() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption] string environment, [ReplOption] bool internalMode = false) => $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + + var command = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; + + command.Options.Should().ContainSingle(option => option.Name == "environment"); + command.Options.Should().NotContain(option => option.Name == "internalMode"); + } + + [TestMethod] + [Description("Selecting an unknown command option target fails clearly instead of leaving the intended option visible.")] + public void When_SelectingUnknownCommandOption_Then_ConfigurationThrows() + { + var sut = CoreReplApp.Create(); + var command = sut.Map("deploy", ([ReplOption] bool force) => force); + + var act = () => command.Option("missing"); + + act.Should().Throw() + .WithMessage("*option target*missing*deploy*"); + } + + [TestMethod] + [Description("Verifies ReplOption.Hidden omits a parameter option from discovery metadata.")] + public void When_CommandOptionHasHiddenAttribute_Then_DocumentationOmitsIt() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption] string environment, [ReplOption(Hidden = true)] bool internalMode = false) => $"{environment}:{internalMode}"); + + var command = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; + + command.Options.Should().ContainSingle(option => option.Name == "environment"); + command.Options.Should().NotContain(option => option.Name == "internalMode"); + } + [TestMethod] [Description("Verifies injected IGlobalOptionsAccessor parameters are omitted from documentation options.")] public void When_HandlerUsesGlobalOptionsAccessor_Then_DocumentationOmitsAccessorOption() diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 34e066bd..a1e5c628 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -1,3 +1,4 @@ +using System.Reflection; using AwesomeAssertions; namespace Repl.Tests; @@ -5,6 +6,20 @@ namespace Repl.Tests; [TestClass] public sealed class Given_GlobalOptionsAccessor { + [TestMethod] + [Description("The historical six-parameter AddGlobalOptionCore descriptor remains available to already-compiled companion assemblies.")] + public void When_InspectingGlobalOptionRegistrationApi_Then_LegacyBinaryDescriptorStillExists() + { + var method = typeof(ParsingOptions).GetMethod( + "AddGlobalOptionCore", + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: [typeof(string), typeof(Type), typeof(string[]), typeof(string), typeof(string), typeof(Type)], + modifiers: null); + + method.Should().NotBeNull(); + } + [TestMethod] [Description("GetValue returns parsed string value after Update.")] public void When_StringOptionParsed_Then_GetValueReturnsIt() diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index 56604727..56774381 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -24,6 +24,61 @@ public async Task When_CurrentTokenIsOptionPrefix_Then_SuggestsRouteAndGlobalOpt result.HintLine.Should().Contain("--force"); } + [TestMethod] + [Description("Hidden route options and all their aliases are omitted from the completion source shared by interactive and shell completion.")] + public async Task When_RouteOptionIsHidden_Then_CompletionOmitsCanonicalAndAliasTokens() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + static string ( + [ReplOption(Aliases = ["-e"])] string environment, + [ReplOption(Name = "internal-mode", Aliases = ["-i"], ReverseAliases = ["--no-internal-mode"])] + [ReplValueAlias("--internal", "true")] bool internalMode) => + $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + + var interactiveLong = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); + var interactiveShort = await ResolveAutocompleteAsync(sut, "deploy -").ConfigureAwait(false); + var shellEngine = new ShellCompletionEngine(sut); + var shellLong = await ResolveShellCandidatesAsync(shellEngine, "app deploy --").ConfigureAwait(false); + var shellShort = await ResolveShellCandidatesAsync(shellEngine, "app deploy -").ConfigureAwait(false); + + var interactiveLongValues = interactiveLong.Suggestions.Select(static suggestion => suggestion.Value).ToArray(); + var interactiveShortValues = interactiveShort.Suggestions.Select(static suggestion => suggestion.Value).ToArray(); + interactiveLongValues.Should().Contain("--environment"); + interactiveLongValues.Should().NotContain("--internal-mode"); + interactiveLongValues.Should().NotContain("--no-internal-mode"); + interactiveLongValues.Should().NotContain("--internal"); + interactiveShortValues.Should().Contain("-e"); + interactiveShortValues.Should().NotContain("-i"); + shellLong.Should().Contain("--environment"); + shellLong.Should().NotContain("--internal-mode"); + shellLong.Should().NotContain("--no-internal-mode"); + shellLong.Should().NotContain("--internal"); + shellShort.Should().Contain("-e"); + shellShort.Should().NotContain("-i"); + } + + [TestMethod] + [Description("A hidden option does not expose its enum values after the caller manually types its token.")] + public async Task When_HiddenEnumOptionAwaitsValue_Then_InteractiveAndShellCompletionReturnNoValues() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + static string ([ReplOption(Name = "secret-mode")] ProbeMode secretMode = ProbeMode.Debug) => secretMode.ToString()) + .Option("secretMode") + .Hidden(); + + var interactive = await ResolveAutocompleteAsync(sut, "deploy --secret-mode ").ConfigureAwait(false); + var shell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --secret-mode ").ConfigureAwait(false); + + interactive.Suggestions.Select(static suggestion => suggestion.Value).Should().BeEmpty(); + shell.Should().BeEmpty(); + } + [TestMethod] [Description("Interactive autocomplete filters option suggestions by a partial option prefix.")] public async Task When_CurrentTokenIsPartialOptionPrefix_Then_FiltersOptionSuggestions() From 93b46201686f6d2b5cfbad9662cb77e68c7eddce Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 21:17:18 -0400 Subject: [PATCH 02/61] ci: skip test-reporter on fork pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dorny/test-reporter creates a check run, which requires `checks: write`. GitHub caps GITHUB_TOKEN to read-only for `pull_request` events raised from a fork, so the step failed with "Resource not accessible by integration" and cascaded into skipping Pack, Validate Repl meta-package, Resolve version and Upload packages — fork PRs never ran packaging validation at all. - gate the step on the PR head repo matching the base repo - keep it running on push builds, where `github.event.pull_request` is null Fork PRs still gate on the Test step and publish the .trx artifact. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a207a15..2b930268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,15 @@ jobs: --coverage-output-format cobertura - name: Test report - if: always() + # Creating a check run needs `checks: write`, but GitHub caps GITHUB_TOKEN + # to read-only for `pull_request` events raised from a fork — the + # workflow-level `permissions:` block cannot lift that ceiling. The action + # then fails with "Resource not accessible by integration", which cascades + # into skipping Pack and the package validation steps below. Fork PRs still + # gate on the Test step and publish the .trx via the test-results artifact. + # The `github.event_name` clause keeps the report alive on push builds, + # where `github.event.pull_request` is null. + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 with: name: Test Results From 78f51a36df4297204681a6626a679514dcd47ad9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 22:25:36 -0400 Subject: [PATCH 03/61] fix(tests): trim stray CR from shell-completion candidates on Windows The completion emitter separates candidates with '\n' but flushes the final line with Environment.NewLine, so on Windows the last candidate carries a trailing '\r'. Splitting on '\n' alone left that CR attached, so the last candidate never compared equal to its expected value. When_CompletingGlobalPrefixWithHiddenCustomOption_Then_HiddenTokensAreNotSuggested registers `-r` as an alias, which sorts after every `--` token and therefore lands last, so it was the one candidate the assertion could not match. The test passed on the Linux and macOS CI legs, where Environment.NewLine is '\n' and RemoveEmptyEntries absorbs the difference. Split with TrimEntries, matching the existing idiom in Given_OutputFormatting. --- src/Repl.IntegrationTests/Given_Completions.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index e4a15f59..4d8fbabd 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -147,7 +147,12 @@ string[] Complete(string line) ])); output.ExitCode.Should().Be(0); - return output.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // The completion emitter separates candidates with '\n', but the final line is flushed + // with Environment.NewLine — so on Windows the last candidate carries a trailing '\r'. + // TrimEntries strips it; without it the last candidate never compares equal, which made + // this test pass on the Linux/macOS CI legs and fail only on Windows. + return output.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } } From c38fc46c5b74851a49b21dd368f45d18d6505f60 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 22:29:23 -0400 Subject: [PATCH 04/61] ci: add windows-latest to the cross-platform test matrix The matrix covered only ubuntu-latest and macos-latest, and build-test-pack runs on ubuntu, so the whole pipeline had zero Windows coverage. That gap let a CRLF-only assertion failure ship green on every leg. Windows is a first-class target for a CLI/REPL framework, and the shell scripts are already pinned to LF via .gitattributes, so the new leg needs no per-OS branching. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b930268..620f8a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + # Windows is a first-class target for a CLI/REPL framework, and its absence let a + # CRLF-only test failure ship green (see the TrimEntries fix in Given_Completions). + os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 8c44dfe4e99fd25cbe10919e361fe00fe3fa2a66 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 22:46:55 -0400 Subject: [PATCH 05/61] test(mcp): pin the tools/call rejection for hidden options; correct the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/commands.md claimed hidden options "remain valid parser inputs and bind normally when supplied explicitly". That is true on the command line and in the REPL, but false over MCP: McpSchemaGenerator and McpToolAdapter's argument allow-list are both built from ReplDocCommand.Options, so removing a hidden option from the documentation model withdraws it from tools/list AND tools/call at once. A hidden option gets the same hard block as a hidden command. - add a characterization pin asserting tools/call rejects a hidden option and the handler does not run - state the MCP behaviour in docs/commands.md instead of contradicting it The pin deliberately does not assert the adapter's own diagnostic: the SDK replaces it with a generic "An error occurred invoking ''." before it reaches the client, which is the right outcome here — a caller probing for a hidden option learns nothing from the failure. Verified with a mutation check: removing the filter in DocumentationEngine makes both this pin and the pre-existing schema-omission test fail. --- docs/commands.md | 4 ++- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 35 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 079d47b5..c089efa2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -90,7 +90,9 @@ app.Options(options => }); ``` -Hidden options are omitted from help, interactive and shell completion (including value providers), documentation export, and generated MCP schemas. They remain valid parser inputs and bind normally when supplied explicitly. +Hidden options are omitted from help, interactive and shell completion (including value providers), documentation export, and generated MCP schemas. They remain valid parser inputs on the command line and in the REPL, and bind normally when supplied explicitly. + +Over MCP the omission is stricter. The advertised tool schema and the list of accepted arguments are built from the same option list, so a `tools/call` that supplies a hidden option is **rejected** — exactly like a call to a hidden command. A hidden option is therefore reachable from a human-driven CLI or REPL session, but not from an agent. A hidden option must be optional. Hiding an option whose effective arity is `ExactlyOne` or `OneOrMore` causes configuration validation to fail before execution or MCP initialization, because discovery clients would otherwise have no valid invocation path. A fluent `.Hidden(isHidden: false)` call overrides `ReplOptionAttribute.Hidden` and can re-expose an attribute-hidden required option before validation runs. diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index cc6186e3..cc9ef89d 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using Repl.Mcp; using Repl.Parameters; @@ -530,6 +531,40 @@ public async Task When_CommandOptionIsHidden_Then_McpToolSchemaOmitsIt() properties.TryGetProperty("internalMode", out _).Should().BeFalse(); } + [TestMethod] + [Description("A hidden option is absent from the advertised schema, so supplying it to tools/call is rejected — the same hard block a hidden command gets. This pins the guarantee that the advertised schema and the accepted argument list can never diverge, because the generated schema omits additionalProperties:false and the allow-list is the only thing enforcing it.")] + public async Task When_HiddenCommandOptionIsSuppliedToToolCall_Then_CallIsRejected() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + }); + + var result = await fixture.Client.CallToolAsync( + toolName: "deploy", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["environment"] = "denim", + ["internalMode"] = true, + }).ConfigureAwait(false); + + var text = string.Join('\n', result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().NotContain("denim", "the handler must not run when an undeclared argument is supplied"); + + // The adapter's own diagnostic ("The MCP argument 'internalMode' is not defined by the + // tool schema.") is deliberately not asserted: the SDK replaces it with a generic + // "An error occurred invoking ''." before it reaches the client. That is the right + // outcome for a hidden option — a caller probing for one learns nothing from the failure. + text.Should().Contain("error occurred invoking"); + } + [TestMethod] [Description("MCP initialization validates required hidden options even when explicit server and Apps settings bypass documentation discovery.")] public async Task When_RequiredCommandOptionIsHiddenAndAppsAreEnabled_Then_McpInitializationFailsClearly() From b25e557e34523ea97d8b4512b3ae96c40767bc85 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:01:40 -0400 Subject: [PATCH 06/61] refactor(core): carry option visibility on the immutable option schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visibility lived on the mutable CommandBuilder, so every discovery surface had to remember to re-test the predicate. Ten sites did; a new one would not have to, and the typo-suggestion path already did not. Move IsHidden onto OptionSchemaParameter and let OptionSchemaBuilder populate it from the ReplOptionAttribute it already holds. That deletes CommandBuilder.CollectAttributeHiddenOptions outright — a second reflection walk over handler parameters and options-group properties, duplicating OptionSchemaBuilder with divergent rules — together with its UnconditionalSuppressMessage(IL2075), whose justification was wrong: a delegate reference does not preserve properties of parameter types under trimming. The surviving options-group walk goes through a DynamicallyAccessedMembers-annotated parameter, which is the established pattern here. Add one shared projection the surfaces consume instead of a local predicate: - OptionSchema.DiscoverableParameters absorbs both the ArgumentOnly and the hidden filter, so help asks one question instead of two - OptionSchema.DiscoverableEntries covers every token of a hidden option — aliases, value aliases, negated flags — not just its canonical form - OptionSchema.IsOptionHidden for the sites that already hold a resolved target OptionTokenCompletionSource loses two overloads and its Func<> parameter: the file whose own doc comment calls it the single source of option-name candidates "so the two surfaces can never drift" was taking a caller-supplied drift vector. It is now one line shorter than on origin/main, having absorbed the feature. Fluent calls after Map publish a new schema through CompareExchange rather than mutating one. Entries are reused verbatim, so parsing and binding observe an identical contract — pinned by a test asserting KnownTokens, resolved arity and the Entries reference are unchanged across a visibility swap. No lock: a blocking wait on another managed thread deadlocks on single-threaded WASM. RouteDefinition reads the schema through the builder instead of snapshotting it, so an already-cached routing graph observes a later change with no cache plumbing. Behaviour-preserving: 1336 tests green, 0 warnings under -warnaserror. --- .../Autocomplete/AutocompleteEngine.cs | 4 +- src/Repl.Core/CommandBuilder.cs | 96 +++++++++---------- src/Repl.Core/CoreReplApp.cs | 4 +- .../Documentation/DocumentationEngine.cs | 4 +- .../Help/HelpTextBuilder.Rendering.cs | 5 +- .../Internal/Options/OptionSchema.cs | 53 ++++++++++ .../Internal/Options/OptionSchemaBuilder.cs | 6 +- .../Internal/Options/OptionSchemaParameter.cs | 3 +- .../Options/OptionTokenCompletionSource.cs | 38 ++------ src/Repl.Core/Routing/RouteDefinition.cs | 8 +- src/Repl.Core/Session/InteractiveSession.cs | 2 +- .../ShellCompletion/ShellCompletionEngine.cs | 6 +- .../Given_CommandBuilderEnrichment.cs | 24 +++++ 13 files changed, 148 insertions(+), 105 deletions(-) diff --git a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs index 526ba0bf..5ae298a1 100644 --- a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs +++ b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs @@ -636,7 +636,7 @@ private ConsoleLineReader.AutocompleteSuggestion[] AppendInvalidAlternativeIfNee && commandPrefix.Length == match.Route.Template.Segments.Count) { OptionTokenCompletionSource.CollectRouteOptionTokens( - match.Route, + match.Route.OptionSchema, currentTokenPrefix, app.OptionsSnapshot.Parsing.OptionCaseSensitivity, dedupe, @@ -1592,7 +1592,7 @@ internal static bool IsControlFreeValue(string value) => pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity); foreach (var entry in entries) { - if (match.Route.Command.IsOptionHidden(entry.ParameterName)) + if (match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) { continue; } diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 0402e5d2..caef3441 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Reflection; using Repl.Internal.Options; @@ -15,14 +14,10 @@ public sealed class CommandBuilder private readonly Dictionary _completionScopes = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _hiddenOptions = - new(StringComparer.OrdinalIgnoreCase); - - private readonly HashSet _attributeHiddenOptions = - new(StringComparer.OrdinalIgnoreCase); - - private HashSet? _knownOptionTargets; - private HashSet? _requiredOptionTargets; + // Option visibility lives on this immutable schema, not on the builder, so every discovery + // surface reads one source. Swapped whole via CompareExchange rather than mutated: no lock, + // because a blocking wait on another managed thread deadlocks on single-threaded WASM. + private OptionSchema _optionSchema = OptionSchema.Empty; /// /// Initializes a new instance of the class. @@ -33,7 +28,6 @@ internal CommandBuilder(string route, Delegate handler) { Route = route; Handler = handler; - CollectAttributeHiddenOptions(handler, _attributeHiddenOptions); SupportsHostedProtocolPassthrough = ComputeSupportsHostedProtocolPassthrough(handler); } @@ -163,69 +157,65 @@ public OptionBuilder Option(string targetName) targetName = string.IsNullOrWhiteSpace(targetName) ? throw new ArgumentException("Option target name cannot be empty.", nameof(targetName)) : targetName; - if (_knownOptionTargets is null || !_knownOptionTargets.Contains(targetName)) + if (!OptionSchema.TryGetParameter(targetName, out var parameter) + || parameter.Mode == ReplParameterMode.ArgumentOnly) { throw new KeyNotFoundException( $"No option target named '{targetName}' is registered for command '{Route}'."); } - return new OptionBuilder(isHidden => _hiddenOptions[targetName] = isHidden); + return new OptionBuilder(isHidden => SetOptionHidden(targetName, isHidden)); } - internal void SetOptionTargets(OptionSchema schema) - { - _knownOptionTargets = schema.Parameters.Values - .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) - .Select(parameter => parameter.Name) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - _requiredOptionTargets = schema.Entries - .Where(entry => - _knownOptionTargets.Contains(entry.ParameterName) - && entry.Arity is ReplArity.ExactlyOne or ReplArity.OneOrMore) - .Select(entry => entry.ParameterName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - } + internal OptionSchema OptionSchema => Volatile.Read(ref _optionSchema); - internal void ValidateOptionMetadata() + internal void AttachOptionSchema(OptionSchema schema) => Volatile.Write(ref _optionSchema, schema); + + /// + /// Publishes a new schema with the target's visibility updated. + /// + /// + /// A retry loop rather than a plain write: the schema is also the parsing contract, so a + /// concurrent visibility change must not drop the other one's update. Unknown targets are + /// impossible here because already rejected them. + /// + private void SetOptionHidden(string targetName, bool isHidden) { - var invalidTarget = _requiredOptionTargets?.FirstOrDefault(IsOptionHidden); - if (invalidTarget is not null) + while (true) { - throw new InvalidOperationException( - $"Option target '{invalidTarget}' for command '{Route}' cannot be hidden because it is required."); - } - } + var current = Volatile.Read(ref _optionSchema); + if (!current.TryGetParameter(targetName, out var parameter)) + { + return; + } - internal bool IsOptionHidden(string targetName) => - _hiddenOptions.TryGetValue(targetName, out var isHidden) - ? isHidden - : _attributeHiddenOptions.Contains(targetName); + if (parameter.IsHidden == isHidden) + { + return; + } - [UnconditionalSuppressMessage( - "Trimming", - "IL2075", - Justification = "Options group types are user-defined and preserved by the handler delegate reference.")] - private static void CollectAttributeHiddenOptions(Delegate handler, HashSet hiddenOptions) - { - foreach (var parameter in handler.Method.GetParameters()) - { - if (!string.IsNullOrWhiteSpace(parameter.Name) - && (parameter.GetCustomAttribute(inherit: true)?.Hidden ?? false)) + var candidate = current.WithParameter(parameter with { IsHidden = isHidden }); + if (ReferenceEquals(Interlocked.CompareExchange(ref _optionSchema, candidate, current), current)) { - hiddenOptions.Add(parameter.Name); + return; } + } + } - if (!Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + internal void ValidateOptionMetadata() + { + var schema = OptionSchema; + foreach (var parameter in schema.Parameters.Values) + { + if (parameter.Mode == ReplParameterMode.ArgumentOnly || !parameter.IsHidden) { continue; } - foreach (var property in parameter.ParameterType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + if (schema.ResolveParameterArity(parameter.Name) is ReplArity.ExactlyOne or ReplArity.OneOrMore) { - if (property.GetCustomAttribute(inherit: true)?.Hidden ?? false) - { - hiddenOptions.Add(property.Name); - } + throw new InvalidOperationException( + $"Option target '{parameter.Name}' for command '{Route}' cannot be hidden because it is required."); } } } diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 0c5f3380..40855b7d 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -181,8 +181,8 @@ public CommandBuilder Map(string route, Delegate handler) _commands.Add(command); var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing, _implicitServiceParameters); - command.SetOptionTargets(optionSchema); - var routeDefinition = new RouteDefinition(template, command, moduleId, optionSchema); + command.AttachOptionSchema(optionSchema); + var routeDefinition = new RouteDefinition(template, command, moduleId); _routes.Add(routeDefinition); InvalidateRouting(); return command; diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 76d53f18..62784629 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -217,7 +217,7 @@ private ReplDocOption[] BuildDocumentationOptions( && parameter.GetCustomAttribute() is null && parameter.GetCustomAttribute() is null && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true) - && !route.Command.IsOptionHidden(parameter.Name!)) + && !route.OptionSchema.IsOptionHidden(parameter.Name!)) .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)); var groupOptions = handlerParams .Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) @@ -225,7 +225,7 @@ private ReplDocOption[] BuildDocumentationOptions( { var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType); return GetOptionsGroupProperties(parameter.ParameterType) - .Where(prop => prop.CanWrite && !route.Command.IsOptionHidden(prop.Name)) + .Where(prop => prop.CanWrite && !route.OptionSchema.IsOptionHidden(prop.Name)) .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); }); return regularOptions.Concat(groupOptions).ToArray(); diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index bd10bd3e..6b444272 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -281,10 +281,7 @@ private static HelpRenderEntry[] BuildOptionRows(RouteDefinition route) } } - return route.OptionSchema.Parameters.Values - .Where(parameter => - parameter.Mode != ReplParameterMode.ArgumentOnly - && !route.Command.IsOptionHidden(parameter.Name)) + return route.OptionSchema.DiscoverableParameters .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters, groupProperties)) .Where(row => row is not null) .Select(row => new HelpRenderEntry(row![0], row[1])) diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 5f88fdce..08822de9 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -28,6 +28,59 @@ public OptionSchema( public IReadOnlyCollection KnownTokens => _knownTokens ??= [.. Entries.Select(entry => entry.Token).Distinct(StringComparer.Ordinal)]; + // Same lazy-materialization contract as KnownTokens: the schema is immutable, every + // discovery surface reads these per keystroke, and a concurrent first read recomputes + // the same result. A visibility change does not mutate a schema — it publishes a new + // one (see WithParameter) — so these caches can never go stale. + private OptionSchemaParameter[]? _discoverableParameters; + private OptionSchemaEntry[]? _discoverableEntries; + + /// + /// Parameters that discovery surfaces may advertise: option-bearing and not hidden. + /// This is the single projection help, documentation and completion consume, so a new + /// surface cannot forget to filter. + /// + public IReadOnlyList DiscoverableParameters => + _discoverableParameters ??= + [ + .. Parameters.Values.Where(parameter => + parameter.Mode != ReplParameterMode.ArgumentOnly && !parameter.IsHidden), + ]; + + /// + /// Entries whose owning parameter is discoverable. Entries outnumber parameters (aliases, + /// value aliases, negated flags), so filtering here keeps every token of a hidden option + /// out of completion, not just its canonical form. + /// + public IReadOnlyList DiscoverableEntries => + _discoverableEntries ??= [.. Entries.Where(entry => !IsOptionHidden(entry.ParameterName))]; + + /// + /// Whether the named parameter is hidden from discovery. Unknown names are not hidden: + /// callers resolve tokens that may belong to route segments rather than options. + /// + public bool IsOptionHidden(string parameterName) => + Parameters.TryGetValue(parameterName, out var parameter) && parameter.IsHidden; + + /// + /// Returns a schema with replacing its same-named entry. + /// + /// + /// The list is reused verbatim, which is what makes a post-registration + /// visibility change safe: tokens, arities and case sensitivity all live on the entries, so + /// parsing and binding observe an identical schema. Only the derived discovery projections + /// differ, and they are recomputed lazily on the new instance. + /// + public OptionSchema WithParameter(OptionSchemaParameter parameter) + { + var parameters = new Dictionary(Parameters, StringComparer.OrdinalIgnoreCase) + { + [parameter.Name] = parameter, + }; + + return new OptionSchema(Entries, parameters); + } + public IReadOnlyList ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity) { if (string.IsNullOrWhiteSpace(token)) diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index d1e69bde..142dd32a 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -120,7 +120,8 @@ private static void AppendParameterSchemaEntries( parameter.ParameterType, mode, CaseSensitivity: optionAttribute?.CaseSensitivityOverride, - ExplicitArity: optionAttribute?.ArityOverride); + ExplicitArity: optionAttribute?.ArityOverride, + IsHidden: optionAttribute?.Hidden ?? false); if (mode == ReplParameterMode.ArgumentOnly) { return; @@ -414,7 +415,8 @@ private static void AppendPropertySchemaEntries( property.PropertyType, mode, CaseSensitivity: optionAttribute?.CaseSensitivityOverride, - ExplicitArity: optionAttribute?.ArityOverride); + ExplicitArity: optionAttribute?.ArityOverride, + IsHidden: optionAttribute?.Hidden ?? false); if (mode != ReplParameterMode.OptionOnly) { positionalPropertyNames.Add(property.Name); diff --git a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs index e506c84d..b1c44e73 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs @@ -7,4 +7,5 @@ internal sealed record OptionSchemaParameter( Type ParameterType, ReplParameterMode Mode, ReplCaseSensitivity? CaseSensitivity = null, - ReplArity? ExplicitArity = null); + ReplArity? ExplicitArity = null, + bool IsHidden = false); diff --git a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs index 8a797f8f..b59eb9fa 100644 --- a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs +++ b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs @@ -88,48 +88,22 @@ internal static void CollectGlobalOptionTokens( } } - /// Collects the route's declared option tokens matching the prefix. - internal static void CollectRouteOptionTokens( - RouteDefinition route, - string currentTokenPrefix, - ReplCaseSensitivity globalCaseSensitivity, - HashSet dedupe, - List results) => - CollectRouteOptionTokens( - route.OptionSchema, - currentTokenPrefix, - globalCaseSensitivity, - dedupe, - results, - entry => !route.Command.IsOptionHidden(entry.ParameterName)); - + /// Collects the route's discoverable option tokens matching the prefix. // Filters against the schema entries — not the flattened KnownTokens — so each option's // own case sensitivity is honored: an entry declared case-insensitive is offered for a // differently-cased prefix even under a case-sensitive global default (and vice versa), - // matching exactly what the invocation parser accepts. + // matching exactly what the invocation parser accepts. Hidden options are excluded by + // DiscoverableEntries rather than by a caller-supplied predicate: a predicate parameter + // was the very drift vector this single source exists to prevent. internal static void CollectRouteOptionTokens( OptionSchema schema, string currentTokenPrefix, ReplCaseSensitivity globalCaseSensitivity, HashSet dedupe, - List results) => - CollectRouteOptionTokens(schema, currentTokenPrefix, globalCaseSensitivity, dedupe, results, include: null); - - private static void CollectRouteOptionTokens( - OptionSchema schema, - string currentTokenPrefix, - ReplCaseSensitivity globalCaseSensitivity, - HashSet dedupe, - List results, - Func? include) + List results) { - foreach (var entry in schema.Entries) + foreach (var entry in schema.DiscoverableEntries) { - if (include is not null && !include(entry)) - { - continue; - } - var comparison = (entry.CaseSensitivity ?? globalCaseSensitivity) == ReplCaseSensitivity.CaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; diff --git a/src/Repl.Core/Routing/RouteDefinition.cs b/src/Repl.Core/Routing/RouteDefinition.cs index 5cdcac54..308ddc7b 100644 --- a/src/Repl.Core/Routing/RouteDefinition.cs +++ b/src/Repl.Core/Routing/RouteDefinition.cs @@ -5,8 +5,7 @@ namespace Repl; internal sealed class RouteDefinition( RouteTemplate template, CommandBuilder command, - int moduleId, - OptionSchema optionSchema) + int moduleId) { public RouteTemplate Template { get; } = template; @@ -14,5 +13,8 @@ internal sealed class RouteDefinition( public int ModuleId { get; } = moduleId; - public OptionSchema OptionSchema { get; } = optionSchema; + // Read through to the builder rather than snapshotting: a fluent visibility change after + // Map publishes a new schema, and every already-cached routing graph holds these same + // RouteDefinition instances — so the change is observed with no cache plumbing. + public OptionSchema OptionSchema => Command.OptionSchema; } diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 2ecd5ca1..3700eb4b 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -733,7 +733,7 @@ internal async ValueTask HandleCompletionAmbientCommandAsync( return false; } - if (match.Route.Command.IsOptionHidden(target) + if (match.Route.OptionSchema.IsOptionHidden(target) || !match.Route.Command.Completions.TryGetValue(target, out var completion)) { await ReplSessionIO.Output.WriteLineAsync($"Error: no completion provider registered for '{target}'.").ConfigureAwait(false); diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs index 07a08f3a..b90a654d 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -424,7 +424,7 @@ private async ValueTask TryAddPendingOptionProviderCandidatesAsync( // A quoted value context is unsafe for provider output (see the positional path); // skipping here lets the static enum fallback still run. if (PrefixHasQuoteContext(currentTokenPrefix) - || !TryResolvePendingRouteOption(match, out var entry) || match.Route.Command.IsOptionHidden(entry.ParameterName) + || !TryResolvePendingRouteOption(match, out var entry) || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName) || !match.Route.Command.Completions.TryGetValue(entry.ParameterName, out var completion) || !match.Route.Command.IsCompletionShellScoped(entry.ParameterName)) { @@ -590,7 +590,7 @@ private bool TryAddRouteEnumValueCandidates( List candidates) { if (!TryResolvePendingRouteOption(match, out var entry) - || match.Route.Command.IsOptionHidden(entry.ParameterName)) + || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) { return false; } @@ -754,7 +754,7 @@ private void AddRouteShellOptionCandidates( List candidates) { OptionTokenCompletionSource.CollectRouteOptionTokens( - route, + route.OptionSchema, currentTokenPrefix, app.OptionsSnapshot.Parsing.OptionCaseSensitivity, dedupe, diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index 395285b6..50310bff 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -240,6 +240,30 @@ public void When_CommandOptionIsHiddenFluently_Then_DocumentationOmitsOnlyThatOp command.Options.Should().NotContain(option => option.Name == "internalMode"); } + [TestMethod] + [Description("Hiding an option after Map publishes a new option schema rather than mutating one, so this asserts the parsing contract survives the swap: the accepted tokens and the resolved arity must be byte-identical before and after, while only the discovery projection changes. Without this, a future change to the swap could silently narrow what the parser accepts.")] + public void When_OptionIsHiddenFluently_Then_ParsingContractIsUnchanged() + { + var sut = CoreReplApp.Create(); + var command = sut.Map( + "deploy", + ([ReplOption] string environment, [ReplOption] bool internalMode = false) => $"{environment}:{internalMode}"); + var before = command.OptionSchema; + string[] tokensBefore = [.. before.KnownTokens]; + var arityBefore = before.ResolveParameterArity("internalMode"); + + command.Option("internalMode").Hidden(); + + var after = command.OptionSchema; + after.Should().NotBeSameAs(before, "visibility is published as a new schema, never mutated in place"); + after.KnownTokens.Should().Equal(tokensBefore, "a hidden option stays fully parsable"); + after.ResolveParameterArity("internalMode").Should().Be(arityBefore); + after.Entries.Should().BeSameAs(before.Entries, "entries carry the parsing contract and are reused verbatim"); + after.IsOptionHidden("internalMode").Should().BeTrue(); + after.DiscoverableParameters.Should().NotContain(parameter => parameter.Name == "internalMode"); + after.DiscoverableParameters.Should().Contain(parameter => parameter.Name == "environment"); + } + [TestMethod] [Description("Selecting an unknown command option target fails clearly instead of leaving the intended option visible.")] public void When_SelectingUnknownCommandOption_Then_ConfigurationThrows() From 6bd6948cf25ada81bd139af325a5dbb06f3370d1 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:09:41 -0400 Subject: [PATCH 07/61] fix(core): invalidate routing when command or option visibility changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived discovery state is rebuilt only when routing is invalidated. Map does that, but the visibility setters did not, so anything captured from Map could retract a command or an option while a live MCP snapshot kept advertising it — help was current, tools/list was stale, and the two disagreed about what exists. - pass the app's InvalidateRouting into CommandBuilder - invoke it from the option-visibility swap and from Hidden, AutomationHidden and WithAnnotations The command-level half of this predates option-level visibility; it is fixed here because it shares the cause and the one-line remedy. Both axes are pinned by tests that list tools, change visibility, and list again. --- src/Repl.Core/CommandBuilder.cs | 14 +++++- src/Repl.Core/CoreReplApp.cs | 2 +- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 48 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index caef3441..ec36e7be 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -19,15 +19,23 @@ public sealed class CommandBuilder // because a blocking wait on another managed thread deadlocks on single-threaded WASM. private OptionSchema _optionSchema = OptionSchema.Empty; + // Derived discovery state (most visibly the MCP tool snapshot) is rebuilt only when routing + // is invalidated, so any visibility change made after Map has to say so. + private readonly Action? _invalidateRouting; + /// /// Initializes a new instance of the class. /// /// The command route template. /// The command handler delegate. - internal CommandBuilder(string route, Delegate handler) + /// + /// Invoked when metadata that discovery surfaces derive from changes after registration. + /// + internal CommandBuilder(string route, Delegate handler, Action? invalidateRouting = null) { Route = route; Handler = handler; + _invalidateRouting = invalidateRouting; SupportsHostedProtocolPassthrough = ComputeSupportsHostedProtocolPassthrough(handler); } @@ -197,6 +205,7 @@ private void SetOptionHidden(string targetName, bool isHidden) var candidate = current.WithParameter(parameter with { IsHidden = isHidden }); if (ReferenceEquals(Interlocked.CompareExchange(ref _optionSchema, candidate, current), current)) { + _invalidateRouting?.Invoke(); return; } } @@ -295,6 +304,7 @@ public CommandBuilder WithBanner(string text) public CommandBuilder Hidden(bool isHidden = true) { IsHidden = isHidden; + _invalidateRouting?.Invoke(); return this; } @@ -414,6 +424,7 @@ public CommandBuilder LongRunning(bool value = true) public CommandBuilder AutomationHidden(bool value = true) { Annotations = (Annotations ?? new CommandAnnotations()) with { AutomationHidden = value }; + _invalidateRouting?.Invoke(); return this; } @@ -429,6 +440,7 @@ public CommandBuilder WithAnnotations(Action configur var builder = new CommandAnnotationsBuilder(); configure(builder); Annotations = builder.Build(); + _invalidateRouting?.Invoke(); return this; } diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 40855b7d..ffc06458 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -168,7 +168,7 @@ public CommandBuilder Map(string route, Delegate handler) : route; ArgumentNullException.ThrowIfNull(handler); - var command = new CommandBuilder(route, handler); + var command = new CommandBuilder(route, handler, InvalidateRouting); ApplyMetadataFromAttributes(command, handler); var parsedTemplate = RouteTemplateParser.Parse(route, _options.Parsing); var template = InferRouteConstraintsFromHandler(parsedTemplate, handler); diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index cc9ef89d..311acbc5 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -565,6 +565,54 @@ public async Task When_HiddenCommandOptionIsSuppliedToToolCall_Then_CallIsReject text.Should().Contain("error occurred invoking"); } + [TestMethod] + [Description("Hiding an option after the first tools/list must withdraw it from the next one. The MCP snapshot is rebuilt only when routing is invalidated, so a visibility change that forgets to invalidate leaves an agent seeing an option the app has retracted.")] + public async Task When_OptionIsHiddenAfterFirstToolsList_Then_SecondToolsListOmitsIt() + { + CommandBuilder? deploy = null; + await using var fixture = await McpTestFixture.CreateAsync(app => + deploy = app.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}")); + + var advertisedBefore = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + deploy!.Option("internalMode").Hidden(); + var advertisedAfter = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + + advertisedBefore.Should().Contain("internalMode"); + advertisedAfter.Should().NotContain("internalMode"); + advertisedAfter.Should().Contain("environment", "hiding one option must not withdraw its siblings"); + } + + [TestMethod] + [Description("Same contract one level up: hiding a whole command after the first tools/list withdraws its tool. This axis predates option-level visibility and shares the missing-invalidation cause, so it is pinned alongside it.")] + public async Task When_CommandIsHiddenAfterFirstToolsList_Then_SecondToolsListOmitsIt() + { + CommandBuilder? wizard = null; + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("ping", () => "pong"); + wizard = app.Map("wizard", () => "interactive"); + }); + + var before = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + wizard!.Hidden(); + var after = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + + before.Should().Contain(tool => string.Equals(tool.Name, "wizard", StringComparison.Ordinal)); + after.Should().NotContain(tool => string.Equals(tool.Name, "wizard", StringComparison.Ordinal)); + after.Should().Contain(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); + } + + private static async Task> ReadDeployPropertiesAsync(McpTestFixture fixture) + { + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(candidate => string.Equals(candidate.Name, "deploy", StringComparison.Ordinal)); + + return [.. tool.JsonSchema.GetProperty("properties").EnumerateObject().Select(static property => property.Name)]; + } + [TestMethod] [Description("MCP initialization validates required hidden options even when explicit server and Apps settings bypass documentation discovery.")] public async Task When_RequiredCommandOptionIsHiddenAndAppsAreEnabled_Then_McpInitializationFailsClearly() From 04805c4d180a8b3a09a530312379ae127204d03a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:18:08 -0400 Subject: [PATCH 08/61] fix(core): validate hidden-required options at configuration time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation ran inside ResolveActiveRoutingGraph, which ResolveUniquePrefixes calls before any handler dispatch. ExecuteCoreAsync has try/finally with no catch and ReplApp.RunAsync catches only HostedServiceLifecycleException, so a misconfiguration escaped Run() as an unhandled exception — for every command, not just the MCP one. It also re-walked every command on each resolution, including the per-keystroke completion path. Move the check to the only phase where throwing is legitimate: - Map validates the schema it just built, rejecting the declarative form - the fluent setter validates the candidate schema before publishing it, so the diagnostic carries the caller's own Hidden() frame The diagnostic now lands on the line that caused it. Both entry points are pinned by tests asserting the throw site, not merely the message. Two consequences, both intended: - CoreReplApp.ValidateOptionMetadata disappears, which reattaches the routing-cache comment to the method it documents - McpServerHandler.RunAsync reverts to a plain async Task. The local RunCoreAsync existed only so validation could throw before the returned Task, and the cross-assembly `_app as CoreReplApp` downcast it needed goes with it. The MCP escape-hatch tests still pass: Map throws inside the fixture's configure callback, so CreateAsync still faults. Accepted cost: .Hidden(false) can no longer rescue an attribute-hidden required option, because Map fails before any fluent call runs. An attribute hiding a required option is a bug in the attribute; the docs now say to fix it there. --- docs/commands.md | 4 ++- docs/parameter-system.md | 2 +- src/Repl.Core/CommandBuilder.cs | 18 ++++++++-- src/Repl.Core/CoreReplApp.cs | 11 +------ .../Given_HelpDiscovery.cs | 32 ++++++++---------- src/Repl.Mcp/McpServerHandler.cs | 33 +++++++------------ 6 files changed, 46 insertions(+), 54 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index c089efa2..6ba3a8d9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -94,7 +94,9 @@ Hidden options are omitted from help, interactive and shell completion (includin Over MCP the omission is stricter. The advertised tool schema and the list of accepted arguments are built from the same option list, so a `tools/call` that supplies a hidden option is **rejected** — exactly like a call to a hidden command. A hidden option is therefore reachable from a human-driven CLI or REPL session, but not from an agent. -A hidden option must be optional. Hiding an option whose effective arity is `ExactlyOne` or `OneOrMore` causes configuration validation to fail before execution or MCP initialization, because discovery clients would otherwise have no valid invocation path. A fluent `.Hidden(isHidden: false)` call overrides `ReplOptionAttribute.Hidden` and can re-expose an attribute-hidden required option before validation runs. +A hidden option must be optional, because a discovery client would otherwise have no valid invocation path. Hiding an option whose effective arity is `ExactlyOne` or `OneOrMore` fails at configuration time: `Map` rejects the declarative form, and `.Hidden()` rejects the fluent one on the spot. The diagnostic therefore lands on the line that caused it, rather than surfacing later from `Run`. + +A fluent `.Hidden(isHidden: false)` still overrides `ReplOptionAttribute.Hidden` for an *optional* option. It cannot rescue a required one: `[ReplOption(Hidden = true)]` on a required option fails while the command is being mapped, before any fluent call can run. Fix the attribute instead. ### Accessing global options outside handlers diff --git a/docs/parameter-system.md b/docs/parameter-system.md index 39bd588e..6df9584a 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -42,7 +42,7 @@ Supporting enums: - `ReplParameterMode` - `ReplArity` -Option visibility can also be configured fluently with `CommandBuilder.Option(targetName).Hidden()` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be optional: effective arity `ExactlyOne` or `OneOrMore` is rejected before execution and discovery-model generation. +Option visibility can also be configured fluently with `CommandBuilder.Option(targetName).Hidden()` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be optional: effective arity `ExactlyOne` or `OneOrMore` is rejected at configuration time — by `Map` for the attribute form, by `.Hidden()` for the fluent one. ### Options groups diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index ec36e7be..c67be4bc 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -203,6 +203,10 @@ private void SetOptionHidden(string targetName, bool isHidden) } var candidate = current.WithParameter(parameter with { IsHidden = isHidden }); + + // Validate before publishing so the throw carries the caller's own Hidden() frame and + // the schema is never left in a state no invocation could satisfy. + ValidateOptionVisibility(candidate); if (ReferenceEquals(Interlocked.CompareExchange(ref _optionSchema, candidate, current), current)) { _invalidateRouting?.Invoke(); @@ -211,9 +215,19 @@ private void SetOptionHidden(string targetName, bool isHidden) } } - internal void ValidateOptionMetadata() + internal void ValidateOptionVisibility() => ValidateOptionVisibility(OptionSchema); + + /// + /// Rejects a hidden option that no invocation could omit. + /// + /// + /// Runs at configuration time only — from for the + /// declarative form and from the fluent setter for the other. It deliberately does not run on any + /// execution or completion path: Run reports failures as an exit code and has no general + /// catch, so throwing from there escapes the host unhandled. + /// + private void ValidateOptionVisibility(OptionSchema schema) { - var schema = OptionSchema; foreach (var parameter in schema.Parameters.Values) { if (parameter.Mode == ReplParameterMode.ArgumentOnly || !parameter.IsHidden) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index ffc06458..507186cd 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -182,6 +182,7 @@ public CommandBuilder Map(string route, Delegate handler) _commands.Add(command); var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing, _implicitServiceParameters); command.AttachOptionSchema(optionSchema); + command.ValidateOptionVisibility(); var routeDefinition = new RouteDefinition(template, command, moduleId); _routes.Add(routeDefinition); InvalidateRouting(); @@ -443,18 +444,8 @@ private ReplRuntimeChannel ResolveCurrentRuntimeChannel() // could later read a completion-time module-absent graph and fail as "Unknown command". // Completion passes useDurableCache: false: it neither reads nor writes the shared cache, // computing a fresh graph scoped to this pass. - internal void ValidateOptionMetadata() - { - foreach (var command in _commands) - { - command.ValidateOptionMetadata(); - } - } - internal ActiveRoutingGraph ResolveActiveRoutingGraph(bool useDurableCache) { - ValidateOptionMetadata(); - var runtime = _runtimeState.Value; var serviceProvider = runtime?.ServiceProvider ?? _services; var channel = ResolveCurrentRuntimeChannel(); diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 0f3b90af..f51e77a1 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -107,38 +107,32 @@ public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStil } [TestMethod] - [Description("A required option cannot be hidden because discovery consumers such as MCP would have no valid invocation path.")] - public void When_RequiredCommandOptionIsHidden_Then_ConfigurationFailsBeforeExecution() + [Description("Hiding a required option is a configuration mistake, so the diagnostic must surface from the offending Hidden() call — with the author's own line on the stack — rather than from a later Run(). Run() returns an exit code and has no general catch, so a throw from there escapes the host unhandled.")] + public void When_RequiredCommandOptionIsHiddenFluently_Then_TheHiddenCallItselfThrows() { var sut = ReplApp.Create(); - sut.Map( - "deploy", - ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) - .Option("internalToken") - .Hidden(); + var command = sut.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken); - var act = () => sut.Run(["deploy", "--internal-token", "secret", "--no-logo"]); + var act = () => command.Option("internalToken").Hidden(); act.Should().Throw() .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); } [TestMethod] - [Description("A fluent Hidden(false) override can re-expose a required option hidden by attribute before configuration validation runs.")] - public void When_RequiredOptionHiddenAttributeIsOverriddenWithFalse_Then_ExplicitInvocationSucceeds() + [Description("The declarative form is rejected at the same phase as the fluent one: Map is where the option schema is built, so an attribute hiding a required option fails there instead of surviving until the first command runs.")] + public void When_RequiredCommandOptionIsHiddenByAttribute_Then_MappingTheCommandThrows() { var sut = ReplApp.Create(); - sut.Map( - "deploy", - ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne, Hidden = true)] string internalToken) => internalToken) - .Option("internalToken") - .Hidden(isHidden: false); - var invocation = ConsoleCaptureHelper.Capture(() => - sut.Run(["deploy", "--internal-token", "secret", "--no-logo"])); + var act = () => sut.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne, Hidden = true)] string internalToken) => internalToken); - invocation.ExitCode.Should().Be(0, invocation.Text); - invocation.Text.Should().Contain("secret"); + act.Should().Throw() + .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); } [TestMethod] diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 3bd78839..2ff84ebe 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -73,41 +73,32 @@ public McpServerHandler( "Trimming", "IL2026", Justification = "MCP server handler runs in a context where all types are preserved.")] - public Task RunAsync(IReplIoContext io, CancellationToken ct) + public async Task RunAsync(IReplIoContext io, CancellationToken ct) { - var coreApp = _app as CoreReplApp - ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); - coreApp.ValidateOptionMetadata(); - var serverOptions = BuildDynamicServerOptions(); var serverName = serverOptions.ServerInfo?.Name ?? "repl-mcp-server"; var transport = _options.TransportFactory is { } factory ? factory(serverName, io) : new StdioServerTransport(serverName); - return RunCoreAsync(); - - async Task RunCoreAsync() + try { + var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); + AttachServer(server); + try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); - - try - { - await server.RunAsync(ct).ConfigureAwait(false); - } - finally - { - UnsubscribeFromRoutingChanges(); - await server.DisposeAsync().ConfigureAwait(false); - } + await server.RunAsync(ct).ConfigureAwait(false); } finally { - await transport.DisposeAsync().ConfigureAwait(false); + UnsubscribeFromRoutingChanges(); + await server.DisposeAsync().ConfigureAwait(false); } } + finally + { + await transport.DisposeAsync().ConfigureAwait(false); + } } internal McpServerOptions BuildDynamicServerOptions() From b29b6836a4cb9d63115c988f02facde5bff29416 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:34:42 -0400 Subject: [PATCH 09/61] test(mcp): make the fixture observe MCP server start failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateAsync launched RunAsync fire-and-forget and then awaited the client handshake, so a server that failed while starting was observable only as an initialize timeout carrying the wrong exception. That is the harness deficiency that once justified making RunAsync throw synchronously. - race the handshake against the server task and rethrow the server's own fault - report a clean early server exit explicitly instead of hanging - release the pipes and the token source when construction fails before the fixture takes ownership of them No longer load-bearing for this branch — configuration now fails inside the configure callback, before RunAsync — but any other start failure still hung, as the new test demonstrates: it timed out before this change. Noted while here, not fixed: configureOptions is invoked twice, once through UseMcpServer and again on a fresh ReplMcpServerOptions, so a registration made inside it lands on two different instances. Pre-existing and out of scope. --- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 17 ++++++++ src/Repl.McpTests/McpTestFixture.cs | 42 +++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 311acbc5..3e3db511 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -605,6 +605,23 @@ public async Task When_CommandIsHiddenAfterFirstToolsList_Then_SecondToolsListOm after.Should().Contain(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); } + [TestMethod] + [Description("A server that fails while starting must surface its own exception from CreateAsync. The fixture used to launch RunAsync fire-and-forget and then await the client handshake, so a start failure was observable only as an initialize timeout carrying the wrong exception — which is what pushed an earlier iteration to make production code throw synchronously just to be testable.")] + public async Task When_ServerStartFails_Then_FixtureSurfacesTheServerFault() + { + var start = async () => await McpTestFixture.CreateAsync( + app => app.Map("ping", () => "pong"), + configureOptions: static options => options.TransportFactory = + static (_, _) => throw new InvalidOperationException("ga-bu-zo-meu: transport refused to start")).ConfigureAwait(false); + + var faulted = await start.Should() + .ThrowAsync() + .WaitAsync(TimeSpan.FromSeconds(15)) + .ConfigureAwait(false); + + faulted.WithMessage("*ga-bu-zo-meu*"); + } + private static async Task> ReadDeployPropertiesAsync(McpTestFixture fixture) { var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 23c90048..05de0132 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -84,9 +84,47 @@ public static async Task CreateAsync( var clientTransport = new StreamClientTransport( clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()); - var client = await McpClient.CreateAsync(clientTransport, clientOptions).ConfigureAwait(false); - return new McpTestFixture(app, client, serverTask, cts, clientToServer, serverToClient); + try + { + // Race the handshake against the server. Awaiting only the client means a server that + // fails while starting is observable solely as an initialize timeout carrying the wrong + // exception — which is what once pushed production code into throwing synchronously + // just to stay testable. + var clientTask = McpClient.CreateAsync(clientTransport, clientOptions); + if (ReferenceEquals(await Task.WhenAny(serverTask, clientTask).ConfigureAwait(false), serverTask)) + { + // Rethrows a start failure; a clean early exit means the handshake never completes. + await serverTask.ConfigureAwait(false); + + throw new InvalidOperationException( + "The MCP server stopped before the client completed its handshake."); + } + + var client = await clientTask.ConfigureAwait(false); + + return new McpTestFixture(app, client, serverTask, cts, clientToServer, serverToClient); + } + catch + { + await AbandonAsync(cts, clientToServer, serverToClient).ConfigureAwait(false); + throw; + } + } + + /// + /// Releases what + /// allocated when construction fails before the fixture takes ownership. + /// + private static async Task AbandonAsync( + CancellationTokenSource cts, + Pipe clientToServer, + Pipe serverToClient) + { + await cts.CancelAsync().ConfigureAwait(false); + await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + cts.Dispose(); } public async ValueTask DisposeAsync() From 0f3fbe03ae58c985e4bafd17d3d13dbb4540b3da Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:50:36 -0400 Subject: [PATCH 10/61] test(core): prove the legacy global-option descriptor forwards, and say why it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review recommended deleting the six-parameter AddGlobalOptionCore forwarder on the grounds that the method is internal and Repl.Defaults reaches it through a ProjectReference, so no compatibility constraint exists. Packing Repl.Defaults shows otherwise: it emits ``, which NuGet reads as a minimum rather than an exact pin. A consumer can therefore run an older compiled Repl.Defaults against a newer Repl.Core, and that binary calls the arity it was built against. Folding isHidden in as a trailing optional parameter would not help — optional parameters are a compile-time convenience and the call site emits the full signature, so folding renames the method as far as the CLR is concerned. Keep the forwarder. Fix what was actually wrong with it: - comment it, naming the exact scenario it protects; an unexplained non-obvious shape was the real defect - replace the reflection existence assertion with one that invokes the arity and checks the option truly lands, visible. The old test passed whether or not the forwarder still forwarded; this one fails when it does not, verified by flipping the forwarded flag. --- src/Repl.Core/ParsingOptions.cs | 7 +++++++ src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index b6946d87..ef6a74d5 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -177,6 +177,13 @@ public OptionBuilder GlobalOption(string name) _globalOptions[definition.Name] = definition with { IsHidden = isHidden }); } + // Kept as a distinct six-parameter signature, not folded into the overload below as a trailing + // optional parameter. Optional parameters are a compile-time convenience: the call site emits + // the full signature, so folding would rename the method as far as the CLR is concerned. + // Repl.Defaults ships as its own package and declares `Repl.Core >= ` — a + // minimum, not an exact pin — so a consumer can legitimately run an older compiled + // Repl.Defaults against a newer Repl.Core. That binary calls this arity; removing it would + // raise MissingMethodException at runtime rather than fail anyone's build. internal void AddGlobalOptionCore( string name, Type valueType, diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index a1e5c628..af1747ab 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -7,17 +7,25 @@ namespace Repl.Tests; public sealed class Given_GlobalOptionsAccessor { [TestMethod] - [Description("The historical six-parameter AddGlobalOptionCore descriptor remains available to already-compiled companion assemblies.")] - public void When_InspectingGlobalOptionRegistrationApi_Then_LegacyBinaryDescriptorStillExists() + [Description("Repl.Defaults ships separately and depends on Repl.Core by minimum version, so an older compiled Repl.Defaults can run against a newer Repl.Core and will call the six-parameter arity it was built against. Asserting the method merely exists would pass even if it stopped registering anything, so this invokes it and checks the option really lands — visible, since that arity predates the visibility flag.")] + public void When_LegacyBinaryDescriptorIsInvoked_Then_TheOptionIsRegisteredAndVisible() { - var method = typeof(ParsingOptions).GetMethod( + var sut = new ParsingOptions(); + var legacy = typeof(ParsingOptions).GetMethod( "AddGlobalOptionCore", BindingFlags.Instance | BindingFlags.NonPublic, binder: null, types: [typeof(string), typeof(Type), typeof(string[]), typeof(string), typeof(string), typeof(Type)], modifiers: null); - method.Should().NotBeNull(); + string[] aliases = ["-d"]; + + legacy.Should().NotBeNull("an already-compiled Repl.Defaults binds to this arity"); + legacy!.Invoke(sut, ["denim-tint", typeof(string), aliases, null, "Visible by construction.", null]); + + var registered = sut.GlobalOptions.Values.Should().ContainSingle().Which; + registered.Name.Should().Be("denim-tint"); + registered.IsHidden.Should().BeFalse(); } [TestMethod] From 0e054582085a31f5faa1fd274e95c04394333662 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 24 Jul 2026 23:59:23 -0400 Subject: [PATCH 11/61] feat(core): give global options their own builder type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParsingOptions.GlobalOption returned the same OptionBuilder that command options use. Command options are about to gain AutomationHidden, and on a global option that could never do anything: global options are consumed before routing and never enter the documentation model, so they cannot reach an MCP tool schema at all. Sharing the type would ship a method that silently does nothing, forever. Split GlobalOptionBuilder off so the invalid state is unrepresentable rather than merely undocumented — the type carries only Hidden. Preferred over a runtime throw, which would turn a compile-time impossibility into a runtime one and still leave the method visible in IntelliSense. Also, while rewiring the setter: - re-read the entry instead of closing over the definition the builder was created from. The closure wrote back a captured record, which was one added mutator away from silently reverting other fields. No test can demonstrate it today because duplicate registration throws, so this is stated rather than covered. - probe the dictionary by key before scanning it, keeping the scan only for a caller passing the rendered token for a bare-registered name. That branch is now pinned by its own test, since a keyed probe alone would miss it. Breaking against this branch only — GlobalOption's return type is unreleased. Nothing on origin/main is affected, and both integration tests that chain .GlobalOption(name).Hidden() compile untouched. --- src/Repl.Core/GlobalOptionBuilder.cs | 39 ++++++++++++++++ src/Repl.Core/ParsingOptions.cs | 44 ++++++++++++++----- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 25 +++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/Repl.Core/GlobalOptionBuilder.cs diff --git a/src/Repl.Core/GlobalOptionBuilder.cs b/src/Repl.Core/GlobalOptionBuilder.cs new file mode 100644 index 00000000..e8a66c1b --- /dev/null +++ b/src/Repl.Core/GlobalOptionBuilder.cs @@ -0,0 +1,39 @@ +namespace Repl; + +/// +/// Configures discovery metadata for a registered global option. +/// +/// +/// Deliberately separate from rather than sharing it. Global options are +/// consumed before routing and never enter the documentation model, so they cannot reach an MCP tool +/// schema — an automation-visibility knob here could never do anything. Splitting the type makes that +/// unrepresentable instead of offering a method that silently does nothing. +/// +public sealed class GlobalOptionBuilder +{ + private readonly ParsingOptions _owner; + private readonly string _canonicalName; + + internal GlobalOptionBuilder(ParsingOptions owner, string canonicalName) + { + _owner = owner; + _canonicalName = canonicalName; + } + + /// + /// Hides or shows the global option on discovery surfaces without changing parsing or binding. + /// + /// + /// The option is omitted from help and from interactive and shell completion, but remains a valid + /// parser input and binds normally when supplied explicitly. This is a discovery filter, not an + /// access-control boundary: the token stays an invocable part of the command line. + /// + /// Whether the option is hidden. + /// The same builder instance. + public GlobalOptionBuilder Hidden(bool isHidden = true) + { + _owner.SetGlobalOptionHidden(_canonicalName, isHidden); + + return this; + } +} diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index ef6a74d5..396901d5 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -160,21 +160,43 @@ public void AddGlobalOption(string name, string constraintOrTypeName, string[]? /// Selects a registered global option for discovery metadata configuration. /// /// Canonical option name, with or without the -- prefix. - /// An option metadata builder. + /// A global-option metadata builder. /// No global option with the supplied canonical name is registered. - public OptionBuilder GlobalOption(string name) + public GlobalOptionBuilder GlobalOption(string name) { - name = string.IsNullOrWhiteSpace(name) + var requested = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) : name.Trim(); - var canonicalToken = NormalizeLongToken(name); - var definition = _globalOptions.Values.FirstOrDefault(option => - string.Equals(option.Name, name, StringComparison.OrdinalIgnoreCase) - || string.Equals(option.CanonicalToken, canonicalToken, StringComparison.OrdinalIgnoreCase)) - ?? throw new KeyNotFoundException($"No global option named '{name}' is registered."); - - return new OptionBuilder(isHidden => - _globalOptions[definition.Name] = definition with { IsHidden = isHidden }); + + return new GlobalOptionBuilder(this, ResolveGlobalOptionKey(requested)); + } + + private string ResolveGlobalOptionKey(string requested) + { + // Keyed probe first; the scan below only covers a caller passing the rendered token + // ("--tenant") for an option registered under its bare name, or the reverse. + if (_globalOptions.ContainsKey(requested)) + { + return requested; + } + + var canonicalToken = NormalizeLongToken(requested); + + return _globalOptions.Values.FirstOrDefault(option => + string.Equals(option.CanonicalToken, canonicalToken, StringComparison.OrdinalIgnoreCase)) + ?.Name + ?? throw new KeyNotFoundException($"No global option named '{requested}' is registered."); + } + + // Re-reads the entry instead of closing over the definition the builder was created from: a + // retained builder must not write back a stale record. Not reachable today, since duplicate + // registration throws, but the closure form was one added mutator away from silently reverting. + internal void SetGlobalOptionHidden(string canonicalName, bool isHidden) + { + if (_globalOptions.TryGetValue(canonicalName, out var definition)) + { + _globalOptions[canonicalName] = definition with { IsHidden = isHidden }; + } } // Kept as a distinct six-parameter signature, not folded into the overload below as a trailing diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index af1747ab..b863b7e7 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -28,6 +28,31 @@ public void When_LegacyBinaryDescriptorIsInvoked_Then_TheOptionIsRegisteredAndVi registered.IsHidden.Should().BeFalse(); } + [TestMethod] + [Description("Global options are consumed before routing and never enter the documentation model, so they cannot reach an MCP tool schema at all. Selecting one therefore returns a builder that deliberately cannot express automation visibility — enforced by the type rather than a runtime throw, so a permanent silent no-op is unrepresentable instead of merely undocumented. There is no behaviour to assert here because the point is that none exists.")] + public void When_SelectingAGlobalOption_Then_TheBuilderCannotExpressAutomationVisibility() + { + var sut = new ParsingOptions(); + sut.AddGlobalOption("tenant"); + + var builder = sut.GlobalOption("tenant"); + + builder.Should().BeOfType(); + typeof(GlobalOptionBuilder).GetMethod("AutomationHidden").Should().BeNull(); + } + + [TestMethod] + [Description("Selection accepts the name either bare or as the rendered token, so an option registered as \"tenant\" is still configurable as \"--tenant\". A keyed probe alone would miss this, which is why the prefixed form is pinned separately from the common case.")] + public void When_SelectingAGlobalOptionByItsRenderedToken_Then_ItResolves() + { + var sut = new ParsingOptions(); + sut.AddGlobalOption("tenant"); + + sut.GlobalOption("--tenant").Hidden(); + + sut.GlobalOptions.Values.Should().ContainSingle().Which.IsHidden.Should().BeTrue(); + } + [TestMethod] [Description("GetValue returns parsed string value after Update.")] public void When_StringOptionParsed_Then_GetValueReturnsIt() From fa3e1e04c6945d5d5291b7e2a7a98f980d5d56e9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 00:09:29 -0400 Subject: [PATCH 12/61] feat(core): model hidden options in the documentation model instead of erasing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #76's implementation notes ask for IsHidden to be carried through the documentation metadata so each renderer can filter it. The branch erased hidden options instead, which left an app author with no way to ask which options are hidden — while hidden *commands* have been modelled and exported all along. Mirror the command axis exactly: - ReplDocOption gains IsHidden, declared in the record body rather than as a positional parameter: the record is public with nine positional parameters and no defaults, so a tenth would change the constructor and Deconstruct arity for every already-compiled consumer - the aggregate model still omits hidden options, which is what keeps them out of the MCP tool schema and its argument allow-list, since MCP always builds the aggregate - a model built for an explicitly targeted command includes them, flagged, the same way `doc export contact debug dump-state` already exports a hidden command with "isHidden": true Two existing tests asserted the old exact-path behaviour and now assert both halves of the contract instead. Restructuring the option filter around `parameter.Name is { } name` also removed the two null-forgiving operators that predicate carried. --- .../Documentation/DocumentationEngine.cs | 42 +++++++++++++------ src/Repl.Core/Documentation/ReplDocOption.cs | 18 +++++++- .../Given_DocumentationExport.cs | 23 ++++++++++ .../Given_CommandBuilderEnrichment.cs | 25 ++++++----- 4 files changed, 85 insertions(+), 23 deletions(-) diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 62784629..89b22dc6 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -65,7 +65,17 @@ public object CreateDocumentationModelInternal(string? targetPath) out var notFoundResult); var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); - var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); + + // Mirrors the command axis: a targeted command exports its hidden options, flagged, the way + // a targeted hidden command exports itself. Aggregate models omit them — which is also what + // keeps them out of the MCP tool schema and its argument allow-list, since MCP always builds + // the aggregate model. + var isExactCommandTarget = commands.Length == 1 + && !string.IsNullOrWhiteSpace(normalizedTargetPath) + && string.Equals(commands[0].Template.Template, normalizedTargetPath, StringComparison.OrdinalIgnoreCase); + var commandDocs = commands + .Select(route => BuildDocumentationCommand(route, includeHiddenOptions: isExactCommandTarget)) + .ToArray(); var contextDocs = contexts .Select(context => new ReplDocContext( Path: context.Template.Template, @@ -171,7 +181,7 @@ private static ContextDefinition[] SelectDocumentationContexts( return selected; } - private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) + private ReplDocCommand BuildDocumentationCommand(RouteDefinition route, bool includeHiddenOptions) { var dynamicSegments = route.Template.Segments .OfType() @@ -181,7 +191,7 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) .ToHashSet(StringComparer.OrdinalIgnoreCase); var handlerParams = route.Command.Handler.Method.GetParameters(); var arguments = BuildDocumentationArguments(dynamicSegments, handlerParams); - var options = BuildDocumentationOptions(route, routeParameterNames, handlerParams); + var options = BuildDocumentationOptions(route, routeParameterNames, handlerParams, includeHiddenOptions); var answers = BuildDocumentationAnswers(route.Command); var acceptsPagingInput = handlerParams.Any(static parameter => parameter.ParameterType == typeof(IReplPagingContext)); var emitsPagedResult = IsPagedReturnType(route.Command.Handler.Method.ReturnType); @@ -206,27 +216,29 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) private ReplDocOption[] BuildDocumentationOptions( RouteDefinition route, HashSet routeParameterNames, - ParameterInfo[] handlerParams) + ParameterInfo[] handlerParams, + bool includeHiddenOptions) { + var schema = route.OptionSchema; var regularOptions = handlerParams .Where(parameter => - !string.IsNullOrWhiteSpace(parameter.Name) + parameter.Name is { } name && parameter.ParameterType != typeof(CancellationToken) - && !routeParameterNames.Contains(parameter.Name!) + && !routeParameterNames.Contains(name) && !app.ImplicitServiceParameters.IsImplicitServiceParameter(parameter.ParameterType) && parameter.GetCustomAttribute() is null && parameter.GetCustomAttribute() is null && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true) - && !route.OptionSchema.IsOptionHidden(parameter.Name!)) - .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)); + && (includeHiddenOptions || !schema.IsOptionHidden(name))) + .Select(parameter => BuildDocumentationOption(schema, parameter)); var groupOptions = handlerParams .Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) .SelectMany(parameter => { var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType); return GetOptionsGroupProperties(parameter.ParameterType) - .Where(prop => prop.CanWrite && !route.OptionSchema.IsOptionHidden(prop.Name)) - .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); + .Where(prop => prop.CanWrite && (includeHiddenOptions || !schema.IsOptionHidden(prop.Name))) + .Select(prop => BuildDocumentationOptionFromProperty(schema, prop, defaultInstance)); }); return regularOptions.Concat(groupOptions).ToArray(); } @@ -420,7 +432,10 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( ReverseAliases: reverseAliases, ValueAliases: valueAliases, EnumValues: enumValues, - DefaultValue: defaultValue); + DefaultValue: defaultValue) + { + IsHidden = schema.IsOptionHidden(property.Name), + }; } private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter) @@ -460,7 +475,10 @@ private static ReplDocOption BuildDocumentationOption(OptionSchema schema, Param ReverseAliases: reverseAliases, ValueAliases: valueAliases, EnumValues: enumValues, - DefaultValue: defaultValue); + DefaultValue: defaultValue) + { + IsHidden = schema.IsOptionHidden(parameter.Name!), + }; } [UnconditionalSuppressMessage( diff --git a/src/Repl.Core/Documentation/ReplDocOption.cs b/src/Repl.Core/Documentation/ReplDocOption.cs index e1508117..97d7ac35 100644 --- a/src/Repl.Core/Documentation/ReplDocOption.cs +++ b/src/Repl.Core/Documentation/ReplDocOption.cs @@ -12,4 +12,20 @@ public sealed record ReplDocOption( IReadOnlyList ReverseAliases, IReadOnlyList ValueAliases, IReadOnlyList EnumValues, - string? DefaultValue); + string? DefaultValue) +{ + // Declared in the record body rather than as a positional parameter: this record is public and + // its nine positional parameters have no defaults, so adding a tenth would change the + // constructor signature and the Deconstruct arity for every already-compiled consumer. + + /// + /// Gets a value indicating whether the option is hidden from discovery surfaces. + /// + /// + /// Mirrors : an aggregate model omits hidden options + /// entirely, while a model built for an explicitly targeted command includes them with this + /// flag set, so an app author can still inventory them. Hidden options remain parsable from the + /// command line and the REPL; consumers that generate agent-facing schemas must omit them. + /// + public bool IsHidden { get; init; } +} diff --git a/src/Repl.IntegrationTests/Given_DocumentationExport.cs b/src/Repl.IntegrationTests/Given_DocumentationExport.cs index fff64ac2..45753bcb 100644 --- a/src/Repl.IntegrationTests/Given_DocumentationExport.cs +++ b/src/Repl.IntegrationTests/Given_DocumentationExport.cs @@ -59,6 +59,29 @@ public void When_ExportingExactHiddenCommand_Then_HiddenCommandIsIncluded() output.Text.Should().Contain("\"isHidden\": true"); } + [TestMethod] + [Description("Mirrors the command axis one level down: an explicitly targeted command exports its hidden options, flagged, exactly as a targeted hidden command exports itself. Aggregate export still omits them, which is what keeps them out of the MCP tool schema and its argument allow-list. This targeted export is the only surface an app author has for asking which options are hidden.")] + public void When_ExportingExactCommandWithHiddenOption_Then_TheOptionIsIncludedAndFlagged() + { + var sut = ReplApp.Create() + .UseDocumentationExport(); + sut.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .Option("internalMode") + .Hidden(); + + var aggregate = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "--json", "--no-logo"])); + var targeted = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--json", "--no-logo"])); + + aggregate.ExitCode.Should().Be(0, aggregate.Text); + aggregate.Text.Should().NotContain("internalMode"); + targeted.ExitCode.Should().Be(0, targeted.Text); + targeted.Text.Should().Contain("internalMode"); + targeted.Text.Should().Contain("\"isHidden\": true"); + } + [TestMethod] [Description("Regression guard: verifies hidden context is explicitly targeted so exact-path export includes the hidden context metadata.")] public void When_ExportingExactHiddenContext_Then_HiddenContextIsIncluded() diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index 50310bff..d3528a71 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -224,8 +224,8 @@ public void When_HandlerHasDescriptionAttribute_Then_ArgumentDescriptionIsPopula } [TestMethod] - [Description("Verifies a command option hidden through the fluent option builder is omitted from discovery while sibling options remain visible.")] - public void When_CommandOptionIsHiddenFluently_Then_DocumentationOmitsOnlyThatOption() + [Description("An option hidden through the fluent builder disappears from the aggregate documentation model — the model MCP builds — but survives, flagged, when its command is targeted explicitly. That mirrors how a hidden command behaves and gives an app author the only way to inventory hidden options.")] + public void When_CommandOptionIsHiddenFluently_Then_OnlyTheAggregateModelOmitsIt() { var sut = CoreReplApp.Create(); sut.Map( @@ -234,10 +234,13 @@ public void When_CommandOptionIsHiddenFluently_Then_DocumentationOmitsOnlyThatOp .Option("internalMode") .Hidden(); - var command = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; + var aggregate = sut.CreateDocumentationModel().Commands.Should().ContainSingle().Which; + var targeted = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; - command.Options.Should().ContainSingle(option => option.Name == "environment"); - command.Options.Should().NotContain(option => option.Name == "internalMode"); + aggregate.Options.Should().ContainSingle(option => option.Name == "environment"); + aggregate.Options.Should().NotContain(option => option.Name == "internalMode"); + targeted.Options.Should().Contain(option => option.Name == "environment" && !option.IsHidden); + targeted.Options.Should().Contain(option => option.Name == "internalMode" && option.IsHidden); } [TestMethod] @@ -278,18 +281,20 @@ public void When_SelectingUnknownCommandOption_Then_ConfigurationThrows() } [TestMethod] - [Description("Verifies ReplOption.Hidden omits a parameter option from discovery metadata.")] - public void When_CommandOptionHasHiddenAttribute_Then_DocumentationOmitsIt() + [Description("The declarative form reaches the same documentation contract as the fluent one: omitted from the aggregate model, present and flagged when the command is targeted.")] + public void When_CommandOptionHasHiddenAttribute_Then_OnlyTheAggregateModelOmitsIt() { var sut = CoreReplApp.Create(); sut.Map( "deploy", ([ReplOption] string environment, [ReplOption(Hidden = true)] bool internalMode = false) => $"{environment}:{internalMode}"); - var command = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; + var aggregate = sut.CreateDocumentationModel().Commands.Should().ContainSingle().Which; + var targeted = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; - command.Options.Should().ContainSingle(option => option.Name == "environment"); - command.Options.Should().NotContain(option => option.Name == "internalMode"); + aggregate.Options.Should().ContainSingle(option => option.Name == "environment"); + aggregate.Options.Should().NotContain(option => option.Name == "internalMode"); + targeted.Options.Should().Contain(option => option.Name == "internalMode" && option.IsHidden); } [TestMethod] From a7853959574aefb2a8185462c877df97e08cf1fd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 00:20:18 -0400 Subject: [PATCH 13/61] feat: add option-level AutomationHidden, mirroring the command axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands could already be withheld from agents while staying visible to humans, via CommandAnnotations.AutomationHidden. Options had only the all-surfaces Hidden, so an option meant for people but not for agents had no expression. Add the second axis at the option level, as two independent booleans rather than an enum, matching how the command axis is modelled: - OptionBuilder.AutomationHidden and ReplOptionAttribute.AutomationHidden - IsAutomationHidden on the internal OptionSchemaParameter, populated by OptionSchemaBuilder from the attribute it already holds - OptionSchema.IsOptionAutomationHidden for consumers - ReplDocOption.IsAutomationHidden, again an init-only property in the record body to keep the public positional constructor intact Unlike Hidden, an automation-hidden option is always kept in the documentation model: human-facing exports and help must still show it, so the flag exists rather than an omission. Only the MCP projection drops it, which lands next. Rejected on typed global-options properties: global options are consumed before routing and never enter the documentation model, so the flag could never act on anything. The fluent path prevents this by type — GlobalOptionBuilder has no such method — but an attribute cannot be type-split, so ThrowIfUnsupportedOverrides gains a branch alongside its CaseSensitivity and Arity siblings. Hidden stays supported there, and that asymmetry is the honest one: one flag has a surface to affect, the other does not. The two visibility setters now share one CompareExchange updater, and attribute versus fluent precedence is pinned in both directions on both axes. XML docs on every new member state the surfaces covered, that tools/call rejects the option, and that neither flag is an access-control boundary. --- src/Repl.Core/CommandBuilder.cs | 19 +++++--- .../Documentation/DocumentationEngine.cs | 2 + src/Repl.Core/Documentation/ReplDocOption.cs | 10 +++++ .../Internal/Options/OptionSchema.cs | 7 +++ .../Internal/Options/OptionSchemaBuilder.cs | 6 ++- .../Internal/Options/OptionSchemaParameter.cs | 3 +- src/Repl.Core/OptionBuilder.cs | 37 +++++++++++++++- .../Attributes/ReplOptionAttribute.cs | 27 +++++++++++- src/Repl.Defaults/GlobalOptionsExtensions.cs | 9 ++++ .../Given_HelpDiscovery.cs | 22 ++++++++++ .../Given_OptionAttributeOverrides.cs | 16 +++++++ .../Given_CommandBuilderEnrichment.cs | 44 +++++++++++++++++++ 12 files changed, 188 insertions(+), 14 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index c67be4bc..6b180e6e 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -172,7 +172,11 @@ public OptionBuilder Option(string targetName) $"No option target named '{targetName}' is registered for command '{Route}'."); } - return new OptionBuilder(isHidden => SetOptionHidden(targetName, isHidden)); + return new OptionBuilder( + isHidden => UpdateOptionParameter(targetName, current => current with { IsHidden = isHidden }), + isAutomationHidden => UpdateOptionParameter( + targetName, + current => current with { IsAutomationHidden = isAutomationHidden })); } internal OptionSchema OptionSchema => Volatile.Read(ref _optionSchema); @@ -180,14 +184,14 @@ public OptionBuilder Option(string targetName) internal void AttachOptionSchema(OptionSchema schema) => Volatile.Write(ref _optionSchema, schema); /// - /// Publishes a new schema with the target's visibility updated. + /// Publishes a new schema with the target parameter's metadata updated. /// /// /// A retry loop rather than a plain write: the schema is also the parsing contract, so a - /// concurrent visibility change must not drop the other one's update. Unknown targets are - /// impossible here because already rejected them. + /// concurrent update must not drop the other one. Unknown targets are impossible here because + /// already rejected them. /// - private void SetOptionHidden(string targetName, bool isHidden) + private void UpdateOptionParameter(string targetName, Func update) { while (true) { @@ -197,12 +201,13 @@ private void SetOptionHidden(string targetName, bool isHidden) return; } - if (parameter.IsHidden == isHidden) + var updated = update(parameter); + if (updated == parameter) { return; } - var candidate = current.WithParameter(parameter with { IsHidden = isHidden }); + var candidate = current.WithParameter(updated); // Validate before publishing so the throw carries the caller's own Hidden() frame and // the schema is never left in a state no invocation could satisfy. diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 89b22dc6..c2a37485 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -435,6 +435,7 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( DefaultValue: defaultValue) { IsHidden = schema.IsOptionHidden(property.Name), + IsAutomationHidden = schema.IsOptionAutomationHidden(property.Name), }; } @@ -478,6 +479,7 @@ private static ReplDocOption BuildDocumentationOption(OptionSchema schema, Param DefaultValue: defaultValue) { IsHidden = schema.IsOptionHidden(parameter.Name!), + IsAutomationHidden = schema.IsOptionAutomationHidden(parameter.Name!), }; } diff --git a/src/Repl.Core/Documentation/ReplDocOption.cs b/src/Repl.Core/Documentation/ReplDocOption.cs index 97d7ac35..76e20af1 100644 --- a/src/Repl.Core/Documentation/ReplDocOption.cs +++ b/src/Repl.Core/Documentation/ReplDocOption.cs @@ -28,4 +28,14 @@ public sealed record ReplDocOption( /// command line and the REPL; consumers that generate agent-facing schemas must omit them. /// public bool IsHidden { get; init; } + + /// + /// Gets a value indicating whether the option is suppressed on programmatic surfaces only. + /// + /// + /// Unlike , an automation-hidden option is always present in this model — + /// human-facing exports keep it, which is why the flag exists rather than an omission. + /// Consumers that generate agent-facing schemas must omit it. Not an access-control boundary. + /// + public bool IsAutomationHidden { get; init; } } diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 08822de9..7439e0d7 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -62,6 +62,13 @@ .. Parameters.Values.Where(parameter => public bool IsOptionHidden(string parameterName) => Parameters.TryGetValue(parameterName, out var parameter) && parameter.IsHidden; + /// + /// Whether the named parameter is hidden from programmatic surfaces only. Independent of + /// : an option can be withheld from agents while staying in help. + /// + public bool IsOptionAutomationHidden(string parameterName) => + Parameters.TryGetValue(parameterName, out var parameter) && parameter.IsAutomationHidden; + /// /// Returns a schema with replacing its same-named entry. /// diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 142dd32a..bfee3fad 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -121,7 +121,8 @@ private static void AppendParameterSchemaEntries( mode, CaseSensitivity: optionAttribute?.CaseSensitivityOverride, ExplicitArity: optionAttribute?.ArityOverride, - IsHidden: optionAttribute?.Hidden ?? false); + IsHidden: optionAttribute?.Hidden ?? false, + IsAutomationHidden: optionAttribute?.AutomationHidden ?? false); if (mode == ReplParameterMode.ArgumentOnly) { return; @@ -416,7 +417,8 @@ private static void AppendPropertySchemaEntries( mode, CaseSensitivity: optionAttribute?.CaseSensitivityOverride, ExplicitArity: optionAttribute?.ArityOverride, - IsHidden: optionAttribute?.Hidden ?? false); + IsHidden: optionAttribute?.Hidden ?? false, + IsAutomationHidden: optionAttribute?.AutomationHidden ?? false); if (mode != ReplParameterMode.OptionOnly) { positionalPropertyNames.Add(property.Name); diff --git a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs index b1c44e73..e2bbfe36 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs @@ -8,4 +8,5 @@ internal sealed record OptionSchemaParameter( ReplParameterMode Mode, ReplCaseSensitivity? CaseSensitivity = null, ReplArity? ExplicitArity = null, - bool IsHidden = false); + bool IsHidden = false, + bool IsAutomationHidden = false); diff --git a/src/Repl.Core/OptionBuilder.cs b/src/Repl.Core/OptionBuilder.cs index e41fc8d2..1134f561 100644 --- a/src/Repl.Core/OptionBuilder.cs +++ b/src/Repl.Core/OptionBuilder.cs @@ -6,20 +6,53 @@ namespace Repl; public sealed class OptionBuilder { private readonly Action _setHidden; + private readonly Action _setAutomationHidden; - internal OptionBuilder(Action setHidden) + internal OptionBuilder(Action setHidden, Action setAutomationHidden) { _setHidden = setHidden; + _setAutomationHidden = setAutomationHidden; } /// - /// Hides or shows the option on discovery surfaces without changing parsing or binding. + /// Hides or shows the option on every discovery surface: help, interactive and shell completion + /// including value providers, exported documentation, and generated MCP tool and prompt schemas. /// + /// + /// Parsing and binding are unaffected, so the option still binds when a command-line or REPL + /// caller supplies it explicitly. An MCP tools/call that supplies it is rejected, because + /// the option is absent from the advertised tool schema. A hidden option must be optional; + /// hiding a required one fails here rather than later. This is a discovery filter, not an + /// access-control boundary: the token remains an invocable part of the command line for anyone + /// who knows it. + /// /// Whether the option is hidden. /// The same builder instance. public OptionBuilder Hidden(bool isHidden = true) { _setHidden(isHidden); + + return this; + } + + /// + /// Hides or shows the option on programmatic surfaces only. + /// + /// + /// Unlike , which hides from all surfaces, this suppresses only MCP tool + /// input schemas and prompt arguments — and, because the advertised schema and the accepted + /// argument list share one option list, an MCP tools/call that supplies the option is + /// rejected too. The option stays visible in help, in interactive and shell completion, and in + /// exported documentation, and binds normally from the command line and the REPL. Mirrors + /// at the option level. This is a discovery + /// filter, not an access-control boundary. + /// + /// Whether the option is hidden from programmatic surfaces. + /// The same builder instance. + public OptionBuilder AutomationHidden(bool isAutomationHidden = true) + { + _setAutomationHidden(isAutomationHidden); + return this; } } diff --git a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs index fd5dde86..fbe2f327 100644 --- a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs +++ b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs @@ -27,11 +27,34 @@ public sealed class ReplOptionAttribute : Attribute public ReplParameterMode Mode { get; set; } = ReplParameterMode.OptionAndPositional; /// - /// Gets or sets a value indicating whether the option is hidden from discovery surfaces. - /// Parsing and binding are unaffected. + /// Gets or sets a value indicating whether the option is hidden from every discovery surface: + /// help, interactive and shell completion including value providers, exported documentation, + /// and generated MCP tool and prompt schemas. /// + /// + /// Parsing and binding are unaffected, so the option still binds when a command-line or REPL + /// caller supplies it explicitly. An MCP tools/call that supplies it is rejected, because + /// the option is absent from the advertised tool schema. A hidden option must be optional. + /// This is a discovery filter, not an access-control boundary: the token remains an + /// invocable part of the command line for anyone who knows it. + /// public bool Hidden { get; set; } + /// + /// Gets or sets a value indicating whether the option is hidden from programmatic surfaces only. + /// + /// + /// Unlike , which hides from all surfaces, this suppresses only MCP tool + /// input schemas and prompt arguments — and, because the advertised schema and the accepted + /// argument list share one option list, an MCP tools/call that supplies the option is + /// rejected too. The option stays visible in help, in interactive and shell completion, and in + /// exported documentation, and binds normally from the command line and the REPL. Mirrors + /// at the option level. Not supported on typed + /// global-options properties, which never reach a programmatic surface. This is a discovery + /// filter, not an access-control boundary. + /// + public bool AutomationHidden { get; set; } + // Nullable enums are not legal attribute named arguments (CS0655), so the optional // overrides expose a non-nullable property and track the unset state in a nullable // backing field surfaced through the read-only *Override properties. diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index d3ce8ec2..844c425b 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -87,6 +87,15 @@ private static void ThrowIfUnsupportedOverrides(Type optionsType, IReadOnlyList< $"Global option property '{optionsType.Name}.{property.Name}' declares an Arity override, which is not supported for typed global options. Remove the override or expose the option through a per-command options type."); } + // Hidden is supported here and flows through to GlobalOptionDefinition, but + // AutomationHidden has nothing to act on: global options never enter the documentation + // model, so they never reach an MCP tool schema. Rejecting beats a silent no-op. + if (optionAttr?.AutomationHidden == true) + { + throw new NotSupportedException( + $"Global option property '{optionsType.Name}.{property.Name}' declares AutomationHidden, which is not supported for typed global options: global options are never exposed to programmatic surfaces, so the flag would have no effect. Remove it, or expose the option through a per-command options type."); + } + foreach (var valueAlias in property.GetCustomAttributes()) { if (valueAlias.CaseSensitivityOverride is not null) diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index f51e77a1..dc05ea5c 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -106,6 +106,28 @@ public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStil invocation.Text.Should().Contain("prod:True"); } + [TestMethod] + [Description("The programmatic-only axis must not leak into human surfaces: an AutomationHidden option stays listed in command help and binds normally from the command line. Only the MCP tool schema drops it.")] + public void When_CommandOptionIsAutomationHidden_Then_HelpStillListsItAndItBinds() + { + var sut = ReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internal-mode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .Option("internalMode") + .AutomationHidden(); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => + sut.Run(["deploy", "--environment", "prod", "--internal-mode", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--internal-mode"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("prod:True"); + } + [TestMethod] [Description("Hiding a required option is a configuration mistake, so the diagnostic must surface from the offending Hidden() call — with the author's own line on the stack — rather than from a later Run(). Run() returns an exit code and has no general catch, so a throw from there escapes the host unhandled.")] public void When_RequiredCommandOptionIsHiddenFluently_Then_TheHiddenCallItselfThrows() diff --git a/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs b/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs index 24187f78..aa54f510 100644 --- a/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs +++ b/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs @@ -37,6 +37,12 @@ public sealed class OverrideArityGlobals public int Retries { get; set; } = 42; } + public sealed class AutomationHiddenGlobals + { + [ReplOption(Name = "internal-tenant", AutomationHidden = true)] + public string? InternalTenant { get; set; } + } + public sealed class ValueAliasOverrideGlobals { [ReplValueAlias("--prod", "production", CaseSensitivity = ReplCaseSensitivity.CaseInsensitive)] @@ -356,6 +362,16 @@ public void When_GlobalOptionsPropertyDeclaresArityOverride_Then_RegistrationFai act.Should().Throw().WithMessage("*Arity*"); } + [TestMethod] + [Description("Global options are consumed before routing and never enter the documentation model, so AutomationHidden on one could never do anything. The fluent path prevents this by type — GlobalOptionBuilder has no such method — but an attribute cannot be type-split, so this branch must fail fast rather than silently discard the flag. Hidden, by contrast, is supported here and must keep working.")] + public void When_GlobalOptionsPropertyDeclaresAutomationHidden_Then_RegistrationFailsFast() + { + var act = () => ReplApp.Create().UseGlobalOptions(); + + act.Should().Throw() + .WithMessage("*AutomationHiddenGlobals.InternalTenant*AutomationHidden*never exposed to programmatic surfaces*"); + } + [TestMethod] [Description("Guards the value-alias branch of the typed-global-options fail-fast: a [ReplValueAlias(..., CaseSensitivity = ...)] override on a global property is equally newly settable and must be rejected instead of silently discarded.")] public void When_GlobalOptionsValueAliasDeclaresOverride_Then_RegistrationFailsFast() diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index d3528a71..dff7790f 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -267,6 +267,50 @@ public void When_OptionIsHiddenFluently_Then_ParsingContractIsUnchanged() after.DiscoverableParameters.Should().Contain(parameter => parameter.Name == "environment"); } + [TestMethod] + [Description("AutomationHidden is the programmatic-only axis: unlike Hidden it keeps the option in the documentation model, so human-facing exports and help still show it and only the MCP projection drops it. Mirrors CommandAnnotations.AutomationHidden one level down.")] + public void When_CommandOptionIsAutomationHidden_Then_TheDocumentationModelKeepsItFlagged() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + ([ReplOption] string environment, [ReplOption] bool internalMode = false) => $"{environment}:{internalMode}") + .Option("internalMode") + .AutomationHidden(); + + var aggregate = sut.CreateDocumentationModel().Commands.Should().ContainSingle().Which; + + aggregate.Options.Should().Contain(option => + option.Name == "internalMode" && option.IsAutomationHidden && !option.IsHidden); + aggregate.Options.Should().Contain(option => + option.Name == "environment" && !option.IsAutomationHidden); + } + + [TestMethod] + [Description("Fluent visibility wins over the attribute on both axes and in both directions, because the attribute seeds the schema at Map while a fluent call publishes a new one afterwards. Asserting a single direction would miss an inverted precedence.")] + public void When_AttributeAndFluentVisibilityDisagree_Then_FluentWins() + { + var sut = CoreReplApp.Create(); + var command = sut.Map( + "deploy", + ([ReplOption(Hidden = true)] bool attributeHidden = false, + [ReplOption(AutomationHidden = true)] bool attributeAutomationHidden = false, + [ReplOption] bool fluentHidden = false, + [ReplOption] bool fluentAutomationHidden = false) => + $"{attributeHidden}{attributeAutomationHidden}{fluentHidden}{fluentAutomationHidden}"); + + command.Option("attributeHidden").Hidden(isHidden: false); + command.Option("attributeAutomationHidden").AutomationHidden(isAutomationHidden: false); + command.Option("fluentHidden").Hidden(); + command.Option("fluentAutomationHidden").AutomationHidden(); + + var schema = command.OptionSchema; + schema.IsOptionHidden("attributeHidden").Should().BeFalse(); + schema.IsOptionAutomationHidden("attributeAutomationHidden").Should().BeFalse(); + schema.IsOptionHidden("fluentHidden").Should().BeTrue(); + schema.IsOptionAutomationHidden("fluentAutomationHidden").Should().BeTrue(); + } + [TestMethod] [Description("Selecting an unknown command option target fails clearly instead of leaving the intended option visible.")] public void When_SelectingUnknownCommandOption_Then_ConfigurationThrows() From c10025958d0da9a0de8a0cade13543975d055dd5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 00:37:33 -0400 Subject: [PATCH 14/61] feat(mcp): honour option-level AutomationHidden at one chokepoint; add WithOption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes, both about a single source of truth. MCP filtering happens once, at the documentation-model boundary in BuildSnapshotCore, rather than in each emitter. The generated tool schema does not declare additionalProperties:false, so McpToolAdapter's argument allow-list is the only thing turning an unadvertised option into a hard error; if schema generation and allow-listing ever read different option lists, an option would vanish from the schema while staying callable. Projecting once makes that unrepresentable, and McpSchemaGenerator, McpToolAdapter, ReplMcpServerPrompt, ReplMcpServerTool and ReplMcpAppLauncherTool need no edits at all. A prompts/list test proves the point: it builds its arguments from its own loop, untouched here, yet still omits the option. The planned unit test against PrepareExecution was dropped rather than written: under this design the projection removes the option before the allow-list sees it, and the allow-list is deliberately flag-blind, so such a test would assert a state production cannot reach. Second, CommandBuilder.WithOption(targetName, configure) — the shape issue #76 also sketched. Now that CommandBuilder and OptionBuilder both expose Hidden and AutomationHidden, a mid-chain `.Option("x").AutomationHidden()` reads exactly like the command-level call while meaning something quite different, and nothing signals that .Option() changed the subject. The callback keeps the subject explicit and the chain on the command. Every chained call site is converted; .Option() stays for the standalone-statement form, and the docs say which to use where. --- docs/commands.md | 14 +++++-- docs/parameter-system.md | 2 +- src/Repl.Core/CommandBuilder.cs | 25 +++++++++++ .../Given_DocumentationExport.cs | 3 +- .../Given_HelpDiscovery.cs | 6 +-- src/Repl.Mcp/McpAutomationProjection.cs | 36 ++++++++++++++++ src/Repl.Mcp/McpServerHandler.cs | 3 +- .../Given_McpFallbackEndToEnd.cs | 21 ++++++++++ src/Repl.McpTests/Given_McpServerEndToEnd.cs | 41 +++++++++++++++---- .../Given_CommandBuilderEnrichment.cs | 6 +-- ...nteractiveAutocomplete_OptionCandidates.cs | 6 +-- 11 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 src/Repl.Mcp/McpAutomationProjection.cs diff --git a/docs/commands.md b/docs/commands.md index 6ba3a8d9..dc94f8f2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -60,16 +60,24 @@ Root help now includes a dedicated `Global Options:` section with built-ins plus ### Hiding individual options -Use `.Option().Hidden()` to hide one command option without hiding its command. The target is the CLR handler parameter or options-group property name, not the rendered `--option-name` token: +Use `.WithOption(, option => option.Hidden())` to hide one command option without hiding its command. The target is the CLR handler parameter or options-group property name, not the rendered `--option-name` token: ```csharp app.Map( "deploy", ([ReplOption(Name = "internal-mode")] bool internalMode = false) => internalMode) - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", option => option.Hidden()); ``` +`.Option()` returns the option builder directly, which is convenient as a standalone statement: + +```csharp +var deploy = app.Map("deploy", Deploy); +deploy.Option("internalMode").Hidden(); +``` + +Prefer `WithOption` inside a fluent chain. `CommandBuilder` and `OptionBuilder` both expose `Hidden` and `AutomationHidden`, so a mid-chain `.Option("x").Hidden()` reads exactly like the command-level call while meaning something quite different — the callback keeps the subject explicit and the chain on the command. + For declarative registration, set `Hidden = true` on `ReplOptionAttribute`. This works on direct parameters, options-group properties, and typed global-options properties: ```csharp diff --git a/docs/parameter-system.md b/docs/parameter-system.md index 6df9584a..4bdef8e6 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -42,7 +42,7 @@ Supporting enums: - `ReplParameterMode` - `ReplArity` -Option visibility can also be configured fluently with `CommandBuilder.Option(targetName).Hidden()` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be optional: effective arity `ExactlyOne` or `OneOrMore` is rejected at configuration time — by `Map` for the attribute form, by `.Hidden()` for the fluent one. +Option visibility can also be configured fluently with `CommandBuilder.WithOption(targetName, option => option.Hidden())` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be optional: effective arity `ExactlyOne` or `OneOrMore` is rejected at configuration time — by `Map` for the attribute form, by `.Hidden()` for the fluent one. ### Options groups diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 6b180e6e..35471b2e 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -155,9 +155,34 @@ public CommandBuilder WithAlias(params string[] aliases) return this; } + /// + /// Configures one option's metadata and stays on the command builder. + /// + /// + /// Prefer this over inside a fluent chain. Both this type and + /// expose Hidden and AutomationHidden, so + /// .Option("x").Hidden() mid-chain reads exactly like the command-level call while meaning + /// something entirely different. The callback makes the subject explicit and the chain never + /// silently changes what it is configuring. + /// + /// Handler parameter or options-group property name. + /// Receives the option metadata builder. + /// The same command builder instance. + public CommandBuilder WithOption(string targetName, Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + configure(Option(targetName)); + + return this; + } + /// /// Selects a handler parameter or options-group property for option metadata configuration. /// + /// + /// Returns the option builder, so the chain stops being about the command. Inside a longer chain + /// prefer , which keeps the subject explicit. + /// /// Handler parameter or options-group property name. /// An option metadata builder. public OptionBuilder Option(string targetName) diff --git a/src/Repl.IntegrationTests/Given_DocumentationExport.cs b/src/Repl.IntegrationTests/Given_DocumentationExport.cs index 45753bcb..7684e59f 100644 --- a/src/Repl.IntegrationTests/Given_DocumentationExport.cs +++ b/src/Repl.IntegrationTests/Given_DocumentationExport.cs @@ -69,8 +69,7 @@ public void When_ExportingExactCommandWithHiddenOption_Then_TheOptionIsIncludedA "deploy", ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); var aggregate = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "--json", "--no-logo"])); var targeted = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--json", "--no-logo"])); diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index dc05ea5c..8356b03b 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -92,8 +92,7 @@ public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStil "deploy", ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internal-mode")] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); var invocation = ConsoleCaptureHelper.Capture(() => @@ -115,8 +114,7 @@ public void When_CommandOptionIsAutomationHidden_Then_HelpStillListsItAndItBinds "deploy", ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internal-mode")] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .AutomationHidden(); + .WithOption("internalMode", static option => option.AutomationHidden()); var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); var invocation = ConsoleCaptureHelper.Capture(() => diff --git a/src/Repl.Mcp/McpAutomationProjection.cs b/src/Repl.Mcp/McpAutomationProjection.cs new file mode 100644 index 00000000..8d023f04 --- /dev/null +++ b/src/Repl.Mcp/McpAutomationProjection.cs @@ -0,0 +1,36 @@ +using Repl.Documentation; + +namespace Repl.Mcp; + +/// +/// Removes automation-hidden options from a documentation model before anything MCP-facing reads it. +/// +/// +/// Applied once, at the model boundary, rather than at each MCP emitter. The generated tool schema +/// does not declare additionalProperties: false, so 's argument +/// allow-list is the only thing that turns an unadvertised option into a hard error. Should schema +/// generation and allow-listing ever read different option lists, an option would silently vanish +/// from the schema while remaining callable — projecting once makes that divergence unrepresentable. +/// +/// Fully hidden options never arrive here: MCP always builds the aggregate documentation model, which +/// omits them already. Only the programmatic-only axis needs filtering. +/// +/// +internal static class McpAutomationProjection +{ + public static ReplDocumentationModel Apply(ReplDocumentationModel model) + { + if (!model.Commands.Any(static command => command.Options.Any(static option => option.IsAutomationHidden))) + { + return model; + } + + var projected = model.Commands + .Select(static command => command.Options.Any(static option => option.IsAutomationHidden) + ? command with { Options = [.. command.Options.Where(static option => !option.IsAutomationHidden)] } + : command) + .ToArray(); + + return model with { Commands = projected }; + } +} diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2ff84ebe..589059ab 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -354,7 +354,8 @@ private async ValueTask GetSnapshotAsync( private McpGeneratedSnapshot BuildSnapshotCore() { - var model = CreateDocumentationModel(); + // Project once here so tools/list, tools/call and prompts/list all read the same option list. + var model = McpAutomationProjection.Apply(CreateDocumentationModel()); var adapter = new McpToolAdapter(_app, _options, _sessionServices); var commandsByPath = model.Commands.ToDictionary( command => command.Path, diff --git a/src/Repl.McpTests/Given_McpFallbackEndToEnd.cs b/src/Repl.McpTests/Given_McpFallbackEndToEnd.cs index 344ee59a..e78ed76e 100644 --- a/src/Repl.McpTests/Given_McpFallbackEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpFallbackEndToEnd.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using Repl.Parameters; namespace Repl.McpTests; @@ -91,6 +92,26 @@ public async Task When_PromptFallbackEnabled_Then_StillInPromptsList() prompts.Should().ContainSingle(p => string.Equals(p.Name, "explain", StringComparison.Ordinal)); } + [TestMethod] + [Description("Proves the automation filter is applied once at the documentation-model boundary rather than per emitter: prompts/list builds its arguments from its own loop over command options, untouched by this change, yet it still omits an automation-hidden option.")] + public async Task When_PromptCommandHasAnAutomationHiddenOption_Then_PromptArgumentsOmitIt() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map( + "explain {topic}", + (string topic, [ReplOption(Name = "verbosity")] string? verbosity = null, [ReplOption(Name = "traceId")] string? traceId = null) => + $"Explain {topic} {verbosity} {traceId}") + .AsPrompt() + .WithOption("traceId", static option => option.AutomationHidden())); + + var prompts = await fixture.Client.ListPromptsAsync().ConfigureAwait(false); + + var prompt = prompts.Should().ContainSingle(p => string.Equals(p.Name, "explain", StringComparison.Ordinal)).Which; + var argumentNames = prompt.ProtocolPrompt.Arguments?.Select(static argument => argument.Name).ToArray() ?? []; + argumentNames.Should().Contain("verbosity"); + argumentNames.Should().NotContain("traceId"); + } + // ── Mixed scenarios ──────────────────────────────────────────────── [TestMethod] diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 3e3db511..17180544 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -519,8 +519,7 @@ public async Task When_CommandOptionIsHidden_Then_McpToolSchemaOmitsIt() "deploy", ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); }); var tools = await fixture.Client.ListToolsAsync(); @@ -541,8 +540,7 @@ public async Task When_HiddenCommandOptionIsSuppliedToToolCall_Then_CallIsReject "deploy", ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); }); var result = await fixture.Client.CallToolAsync( @@ -565,6 +563,35 @@ public async Task When_HiddenCommandOptionIsSuppliedToToolCall_Then_CallIsReject text.Should().Contain("error occurred invoking"); } + [TestMethod] + [Description("The MCP half of the AutomationHidden pair: the advertised tool schema omits the option, and because the schema and the accepted argument list are built from the same option list, tools/call rejects it too. The help half is asserted separately, where the same option stays listed and binds.")] + public async Task When_CommandOptionIsAutomationHidden_Then_McpOmitsItAndRejectsIt() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .WithOption("internalMode", static option => option.AutomationHidden()); + }); + + var advertised = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + var result = await fixture.Client.CallToolAsync( + toolName: "deploy", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["environment"] = "denim", + ["internalMode"] = true, + }).ConfigureAwait(false); + + advertised.Should().Contain("environment"); + advertised.Should().NotContain("internalMode"); + result.IsError.Should().BeTrue(); + string.Join('\n', result.Content.OfType().Select(static block => block.Text)) + .Should().NotContain("denim", "the handler must not run for an argument the schema never advertised"); + } + [TestMethod] [Description("Hiding an option after the first tools/list must withdraw it from the next one. The MCP snapshot is rebuilt only when routing is invalidated, so a visibility change that forgets to invalidate leaves an agent seeing an option the app has retracted.")] public async Task When_OptionIsHiddenAfterFirstToolsList_Then_SecondToolsListOmitsIt() @@ -642,8 +669,7 @@ public async Task When_RequiredCommandOptionIsHiddenAndAppsAreEnabled_Then_McpIn app.Map( "deploy", ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) - .Option("internalToken") - .Hidden(); + .WithOption("internalToken", static option => option.Hidden()); }, configureOptions: options => { @@ -669,8 +695,7 @@ public async Task When_RequiredCommandOptionIsHiddenAndUiResourceIsRegistered_Th app.Map( "deploy", ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) - .Option("internalToken") - .Hidden(); + .WithOption("internalToken", static option => option.Hidden()); }, configureOptions: options => { diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index dff7790f..3169e7bf 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -231,8 +231,7 @@ public void When_CommandOptionIsHiddenFluently_Then_OnlyTheAggregateModelOmitsIt sut.Map( "deploy", ([ReplOption] string environment, [ReplOption] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); var aggregate = sut.CreateDocumentationModel().Commands.Should().ContainSingle().Which; var targeted = sut.CreateDocumentationModel("deploy").Commands.Should().ContainSingle().Which; @@ -275,8 +274,7 @@ public void When_CommandOptionIsAutomationHidden_Then_TheDocumentationModelKeeps sut.Map( "deploy", ([ReplOption] string environment, [ReplOption] bool internalMode = false) => $"{environment}:{internalMode}") - .Option("internalMode") - .AutomationHidden(); + .WithOption("internalMode", static option => option.AutomationHidden()); var aggregate = sut.CreateDocumentationModel().Commands.Should().ContainSingle().Which; diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index 56774381..24bc9f09 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -36,8 +36,7 @@ static string ( [ReplOption(Name = "internal-mode", Aliases = ["-i"], ReverseAliases = ["--no-internal-mode"])] [ReplValueAlias("--internal", "true")] bool internalMode) => $"{environment}:{internalMode}") - .Option("internalMode") - .Hidden(); + .WithOption("internalMode", static option => option.Hidden()); var interactiveLong = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); var interactiveShort = await ResolveAutocompleteAsync(sut, "deploy -").ConfigureAwait(false); @@ -69,8 +68,7 @@ public async Task When_HiddenEnumOptionAwaitsValue_Then_InteractiveAndShellCompl sut.Map( "deploy", static string ([ReplOption(Name = "secret-mode")] ProbeMode secretMode = ProbeMode.Debug) => secretMode.ToString()) - .Option("secretMode") - .Hidden(); + .WithOption("secretMode", static option => option.Hidden()); var interactive = await ResolveAutocompleteAsync(sut, "deploy --secret-mode ").ConfigureAwait(false); var shell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --secret-mode ").ConfigureAwait(false); From e2eed1911456a120ea43d6d805fc08ab339c7dca Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 08:55:42 -0400 Subject: [PATCH 15/61] refactor(core)!: make WithOption the only option-configuration entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandBuilder.Option returned the option builder, so a chain could switch subject silently. Since both CommandBuilder and OptionBuilder now expose Hidden and AutomationHidden, `.Option("x").AutomationHidden()` mid-chain read exactly like the command-level call while meaning something quite different — and nothing in the reading signalled the change. Remove the public Option(string) and keep only WithOption(targetName, configure). The ambiguous form is now impossible to write rather than merely discouraged, and the public surface drops a method. Selection survives as a private helper. All twelve call sites move to the callback form. Breaking against this branch only — Option(string) is unreleased and nothing on origin/main uses it. --- docs/commands.md | 9 +----- src/Repl.Core/CommandBuilder.cs | 28 +++++++------------ .../Given_Completions.cs | 2 +- .../Given_HelpDiscovery.cs | 2 +- .../Given_OptionsGroupBinding.cs | 3 +- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 2 +- .../Given_CommandBuilderEnrichment.cs | 12 ++++---- 7 files changed, 21 insertions(+), 37 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index dc94f8f2..5405b953 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -69,14 +69,7 @@ app.Map( .WithOption("internalMode", option => option.Hidden()); ``` -`.Option()` returns the option builder directly, which is convenient as a standalone statement: - -```csharp -var deploy = app.Map("deploy", Deploy); -deploy.Option("internalMode").Hidden(); -``` - -Prefer `WithOption` inside a fluent chain. `CommandBuilder` and `OptionBuilder` both expose `Hidden` and `AutomationHidden`, so a mid-chain `.Option("x").Hidden()` reads exactly like the command-level call while meaning something quite different — the callback keeps the subject explicit and the chain on the command. +The callback shape is deliberate. `CommandBuilder` and `OptionBuilder` both expose `Hidden` and `AutomationHidden`, so an API returning the option builder would let `.Option("x").Hidden()` sit in a chain reading exactly like the command-level `.Hidden()` while meaning something quite different, with nothing to signal that the subject had changed. `WithOption` keeps the subject explicit and the chain on the command. For declarative registration, set `Hidden = true` on `ReplOptionAttribute`. This works on direct parameters, options-group properties, and typed global-options properties: diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 35471b2e..001f3593 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -156,36 +156,28 @@ public CommandBuilder WithAlias(params string[] aliases) } /// - /// Configures one option's metadata and stays on the command builder. + /// Configures one option's metadata. /// /// - /// Prefer this over inside a fluent chain. Both this type and - /// expose Hidden and AutomationHidden, so - /// .Option("x").Hidden() mid-chain reads exactly like the command-level call while meaning - /// something entirely different. The callback makes the subject explicit and the chain never - /// silently changes what it is configuring. + /// The callback shape is deliberate: this type and both expose + /// Hidden and AutomationHidden, so an API returning the option builder would let + /// .Option("x").Hidden() sit in a chain reading exactly like the command-level call while + /// meaning something quite different, with nothing to signal that the subject changed. Passing a + /// callback keeps the subject explicit and the chain on the command. /// /// Handler parameter or options-group property name. /// Receives the option metadata builder. /// The same command builder instance. + /// No such option target is registered for this command. public CommandBuilder WithOption(string targetName, Action configure) { ArgumentNullException.ThrowIfNull(configure); - configure(Option(targetName)); + configure(SelectOption(targetName)); return this; } - /// - /// Selects a handler parameter or options-group property for option metadata configuration. - /// - /// - /// Returns the option builder, so the chain stops being about the command. Inside a longer chain - /// prefer , which keeps the subject explicit. - /// - /// Handler parameter or options-group property name. - /// An option metadata builder. - public OptionBuilder Option(string targetName) + private OptionBuilder SelectOption(string targetName) { targetName = string.IsNullOrWhiteSpace(targetName) ? throw new ArgumentException("Option target name cannot be empty.", nameof(targetName)) @@ -214,7 +206,7 @@ public OptionBuilder Option(string targetName) /// /// A retry loop rather than a plain write: the schema is also the parsing contract, so a /// concurrent update must not drop the other one. Unknown targets are impossible here because - /// already rejected them. + /// already rejected them. /// private void UpdateOptionParameter(string targetName, Func update) { diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index 4d8fbabd..a532bde8 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -36,7 +36,7 @@ public void When_InteractiveCompleteTargetsHiddenOption_Then_ProviderIsNotInvoke command.WithCompletion( "secretMode", static (_, _, _) => ValueTask.FromResult>(["internal-debug"])); - command.Option("secretMode").Hidden(); + command.WithOption("secretMode", option => option.Hidden()); var output = ConsoleCaptureHelper.CaptureWithInput( "complete deploy --target secretMode\nexit\n", diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 8356b03b..8129d039 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -135,7 +135,7 @@ public void When_RequiredCommandOptionIsHiddenFluently_Then_TheHiddenCallItselfT "deploy", ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken); - var act = () => command.Option("internalToken").Hidden(); + var act = () => command.WithOption("internalToken", option => option.Hidden()); act.Should().Throw() .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); diff --git a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs index f3b4466a..3a0f0961 100644 --- a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -172,8 +172,7 @@ public void When_HiddenAttributeIsOverriddenFluentlyWithFalse_Then_OptionIsVisib { var sut = ReplApp.Create(); sut.Map("list", (HiddenOutputOptions options) => "ok") - .Option(nameof(HiddenOutputOptions.InternalToken)) - .Hidden(isHidden: false); + .WithOption(nameof(HiddenOutputOptions.InternalToken), static option => option.Hidden(isHidden: false)); var help = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--help", "--no-logo"])); diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 17180544..5845979a 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -604,7 +604,7 @@ public async Task When_OptionIsHiddenAfterFirstToolsList_Then_SecondToolsListOmi $"{environment}:{internalMode}")); var advertisedBefore = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); - deploy!.Option("internalMode").Hidden(); + deploy!.WithOption("internalMode", option => option.Hidden()); var advertisedAfter = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); advertisedBefore.Should().Contain("internalMode"); diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index 3169e7bf..12985ab3 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -254,7 +254,7 @@ public void When_OptionIsHiddenFluently_Then_ParsingContractIsUnchanged() string[] tokensBefore = [.. before.KnownTokens]; var arityBefore = before.ResolveParameterArity("internalMode"); - command.Option("internalMode").Hidden(); + command.WithOption("internalMode", option => option.Hidden()); var after = command.OptionSchema; after.Should().NotBeSameAs(before, "visibility is published as a new schema, never mutated in place"); @@ -297,10 +297,10 @@ public void When_AttributeAndFluentVisibilityDisagree_Then_FluentWins() [ReplOption] bool fluentAutomationHidden = false) => $"{attributeHidden}{attributeAutomationHidden}{fluentHidden}{fluentAutomationHidden}"); - command.Option("attributeHidden").Hidden(isHidden: false); - command.Option("attributeAutomationHidden").AutomationHidden(isAutomationHidden: false); - command.Option("fluentHidden").Hidden(); - command.Option("fluentAutomationHidden").AutomationHidden(); + command.WithOption("attributeHidden", option => option.Hidden(isHidden: false)); + command.WithOption("attributeAutomationHidden", option => option.AutomationHidden(isAutomationHidden: false)); + command.WithOption("fluentHidden", option => option.Hidden()); + command.WithOption("fluentAutomationHidden", option => option.AutomationHidden()); var schema = command.OptionSchema; schema.IsOptionHidden("attributeHidden").Should().BeFalse(); @@ -316,7 +316,7 @@ public void When_SelectingUnknownCommandOption_Then_ConfigurationThrows() var sut = CoreReplApp.Create(); var command = sut.Map("deploy", ([ReplOption] bool force) => force); - var act = () => command.Option("missing"); + var act = () => command.WithOption("missing", static option => option.Hidden()); act.Should().Throw() .WithMessage("*option target*missing*deploy*"); From 349d290c49a7724fc40dd23e81692e5bc54b7e45 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 09:12:32 -0400 Subject: [PATCH 16/61] fix: honest diagnostics, and tests that fail when their guard is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics: - the complete ambient command claimed "no completion provider registered" for a hidden target, where one usually is registered. It now says no completion is available, which is true for a hidden target and an unknown one alike, and a comment records that conflating them is deliberate: distinguishing the two would let a caller probe for hidden options through this command. - the required-hidden rejection now names the rendered token as well as the CLR target, plus the remedy. An operator greps argv for '--internal-token' and would not find the parameter name anywhere. Test fidelity: - the hidden-enum completion test asserted two bare BeEmpty results, which would also hold if this shape offered no values at all. A visible sibling is now asserted in the same pass. It passes, which settles the open question: optional enum options do complete their values, so the original assertions were falsifiable and this was a readability gap, not a hidden bug. - the hidden global-option test asserted the bare substring "-t", which held only while no other rendered token contained it. It now asserts rendered option rows, with a visible sibling whose token deliberately contains "-t". - the two MCP initialization tests are deleted rather than merged. Validation now happens at Map, so neither EnableApps nor UiResource has any bearing — the throw precedes any MCP option being read — and their descriptions claimed these settings "bypass documentation discovery", which was never true. The contract is pinned directly on the core side. Readability: the five-clause shell-completion guard becomes a named predicate rather than a crammed condition; splitting it inline pushed the method past the length analyzer, and naming it was the better answer than a suppression. --- src/Repl.Core/CommandBuilder.cs | 8 ++- src/Repl.Core/Session/InteractiveSession.cs | 6 ++- .../ShellCompletion/ShellCompletionEngine.cs | 26 +++++++--- .../Given_Completions.cs | 18 ++++--- .../Given_GlobalOptionsAccessor.cs | 12 +++-- .../Given_HelpDiscovery.cs | 4 +- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 52 ------------------- ...nteractiveAutocomplete_OptionCandidates.cs | 20 ++++--- 8 files changed, 67 insertions(+), 79 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 001f3593..1ef34b84 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -259,8 +259,14 @@ private void ValidateOptionVisibility(OptionSchema schema) if (schema.ResolveParameterArity(parameter.Name) is ReplArity.ExactlyOne or ReplArity.OneOrMore) { + // Name the rendered token as well as the CLR target: an operator greps argv for + // '--internal-token' and would not find the parameter name anywhere. + var token = schema.ResolveDisplayToken(parameter.Name); + var rendered = token is null ? string.Empty : $" (rendered as '{token}')"; + throw new InvalidOperationException( - $"Option target '{parameter.Name}' for command '{Route}' cannot be hidden because it is required."); + $"Option target '{parameter.Name}'{rendered} for command '{Route}' cannot be hidden because it is required. " + + "Either drop the Required/Arity constraint on the option, or hide a different one."); } } } diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 3700eb4b..2add4b6b 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -733,10 +733,14 @@ internal async ValueTask HandleCompletionAmbientCommandAsync( return false; } + // A hidden target and an unknown one are deliberately conflated, and the wording avoids + // "no provider registered" because for a hidden option one usually is. Distinguishing the + // two, or admitting a provider exists, would let a caller probe for hidden options through + // this command — the exact discovery this flag exists to prevent. if (match.Route.OptionSchema.IsOptionHidden(target) || !match.Route.Command.Completions.TryGetValue(target, out var completion)) { - await ReplSessionIO.Output.WriteLineAsync($"Error: no completion provider registered for '{target}'.").ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync($"Error: no completion is available for '{target}'.").ConfigureAwait(false); return false; } diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs index b90a654d..a2b76a45 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -410,6 +410,25 @@ private static void AbandonProvider(CancellationTokenSource deadline, Task provi deadline.Dispose(); }); + // The pending route option has a provider this bridge may run: it resolves, it is discoverable, + // and it opted into shell scope. A quoted value context is unsafe for provider output (see the + // positional path), and bailing here still lets the static enum fallback run. + private bool TryResolveShellScopedProvider( + RouteMatch match, + string currentTokenPrefix, + out OptionSchemaEntry entry, + out CompletionDelegate completion) + { + entry = null!; + completion = null!; + + return !PrefixHasQuoteContext(currentTokenPrefix) + && TryResolvePendingRouteOption(match, out entry) + && !match.Route.OptionSchema.IsOptionHidden(entry.ParameterName) + && match.Route.Command.Completions.TryGetValue(entry.ParameterName, out completion!) + && match.Route.Command.IsCompletionShellScoped(entry.ParameterName); + } + // Runs the pending route option's value provider when it opted into the shell bridge. // Returns true when the provider ran (its answer is final, even when empty), so an enum // fallback never overrides an explicit provider — the interactive menu's precedence. @@ -421,12 +440,7 @@ private async ValueTask TryAddPendingOptionProviderCandidatesAsync( List candidates, CancellationToken cancellationToken) { - // A quoted value context is unsafe for provider output (see the positional path); - // skipping here lets the static enum fallback still run. - if (PrefixHasQuoteContext(currentTokenPrefix) - || !TryResolvePendingRouteOption(match, out var entry) || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName) - || !match.Route.Command.Completions.TryGetValue(entry.ParameterName, out var completion) - || !match.Route.Command.IsCompletionShellScoped(entry.ParameterName)) + if (!TryResolveShellScopedProvider(match, currentTokenPrefix, out var entry, out var completion)) { return false; } diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index a532bde8..bfe62ada 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -26,8 +26,8 @@ public void When_InteractiveCompleteCommandIsUsed_Then_CompletionProviderCandida } [TestMethod] - [Description("The interactive complete ambient command cannot invoke a completion provider belonging to a hidden option.")] - public void When_InteractiveCompleteTargetsHiddenOption_Then_ProviderIsNotInvoked() + [Description("A hidden option's completion provider must not be reachable through the complete ambient command, and the refusal must not reveal that the option exists. Asserting the wording is identical to the unknown-target refusal — bar the name — makes the non-disclosure a contract instead of a coincidence, and stops the message from claiming no provider is registered when one is.")] + public void When_InteractiveCompleteTargetsHiddenOption_Then_ItIsIndistinguishableFromAnUnknownTarget() { var sut = ReplApp.Create().UseDefaultInteractive(); var command = sut.Map( @@ -38,13 +38,17 @@ public void When_InteractiveCompleteTargetsHiddenOption_Then_ProviderIsNotInvoke static (_, _, _) => ValueTask.FromResult>(["internal-debug"])); command.WithOption("secretMode", option => option.Hidden()); - var output = ConsoleCaptureHelper.CaptureWithInput( + var hidden = ConsoleCaptureHelper.CaptureWithInput( "complete deploy --target secretMode\nexit\n", () => sut.Run([])); + var unknown = ConsoleCaptureHelper.CaptureWithInput( + "complete deploy --target ga-bu-zo-meu\nexit\n", + () => sut.Run([])); - output.ExitCode.Should().Be(0); - output.Text.Should().Contain("Error: no completion provider registered for 'secretMode'."); - output.Text.Should().NotContain("internal-debug"); + hidden.ExitCode.Should().Be(0); + hidden.Text.Should().Contain("Error: no completion is available for 'secretMode'."); + hidden.Text.Should().NotContain("internal-debug"); + unknown.Text.Should().Contain("Error: no completion is available for 'ga-bu-zo-meu'."); } [TestMethod] @@ -59,7 +63,7 @@ public void When_InteractiveCompleteCommandUsesUnknownTarget_Then_ErrorIsRendere () => sut.Run([])); output.ExitCode.Should().Be(0); - output.Text.Should().Contain("Error: no completion provider registered for 'missing'."); + output.Text.Should().Contain("Error: no completion is available for 'missing'."); } [TestMethod] diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index e89cd702..47df630d 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -23,8 +23,8 @@ public void When_GlobalOptionProvided_Then_HandlerCanReadItViaAccessor() } [TestMethod] - [Description("A manually registered hidden global option is omitted from help while remaining available through the accessor when explicitly provided.")] - public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() + [Description("A manually registered hidden global option is omitted from help — token, alias, description and default alike — while staying bindable through the accessor. A visible sibling whose own token contains the hidden one's alias is registered on purpose: asserting the bare substring \"-t\" would pass only for as long as no other rendered token happened to contain it, so the assertions target the rendered option row instead.")] + public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItsRowAndExplicitInvocationStillBinds() { var sut = ReplApp.Create(); sut.Options(options => @@ -34,6 +34,11 @@ public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItAndExplicitInvocatio aliases: ["-t"], defaultValue: "internal-default", description: "Internal tenant selector."); + options.Parsing.AddGlobalOption( + "denim-tint", + aliases: ["-d"], + defaultValue: null, + description: "Visible control."); options.Parsing.GlobalOption("tenant").Hidden(); }); sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("tenant") ?? "none"); @@ -44,9 +49,10 @@ public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItAndExplicitInvocatio help.ExitCode.Should().Be(0); help.Text.Should().NotContain("--tenant"); - help.Text.Should().NotContain("-t"); + help.Text.Should().NotContain("--tenant, -t"); help.Text.Should().NotContain("Internal tenant selector."); help.Text.Should().NotContain("internal-default"); + help.Text.Should().Contain("--denim-tint, -d", "the visible sibling proves the whole option table was not simply empty"); invocation.ExitCode.Should().Be(0, invocation.Text); invocation.Text.Should().Contain("acme"); } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 8129d039..a5f282af 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -138,7 +138,7 @@ public void When_RequiredCommandOptionIsHiddenFluently_Then_TheHiddenCallItselfT var act = () => command.WithOption("internalToken", option => option.Hidden()); act.Should().Throw() - .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); + .WithMessage("Option target 'internalToken' (rendered as '--internal-token') for command 'deploy' cannot be hidden because it is required. Either drop the Required/Arity constraint on the option, or hide a different one."); } [TestMethod] @@ -152,7 +152,7 @@ public void When_RequiredCommandOptionIsHiddenByAttribute_Then_MappingTheCommand ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne, Hidden = true)] string internalToken) => internalToken); act.Should().Throw() - .WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); + .WithMessage("Option target 'internalToken' (rendered as '--internal-token') for command 'deploy' cannot be hidden because it is required. Either drop the Required/Arity constraint on the option, or hide a different one."); } [TestMethod] diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 5845979a..d936bb54 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -657,58 +657,6 @@ private static async Task> ReadDeployPropertiesAsync(McpTestFixture return [.. tool.JsonSchema.GetProperty("properties").EnumerateObject().Select(static property => property.Name)]; } - [TestMethod] - [Description("MCP initialization validates required hidden options even when explicit server and Apps settings bypass documentation discovery.")] - public async Task When_RequiredCommandOptionIsHiddenAndAppsAreEnabled_Then_McpInitializationFailsClearly() - { - Func act = async () => - { - var fixture = await McpTestFixture.CreateAsync( - app => - { - app.Map( - "deploy", - ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) - .WithOption("internalToken", static option => option.Hidden()); - }, - configureOptions: options => - { - options.ServerName = "issue-76-test"; - options.EnableApps = true; - }).ConfigureAwait(false); - await fixture.DisposeAsync().ConfigureAwait(false); - }; - - var exception = await act.Should().ThrowAsync().ConfigureAwait(false); - exception.WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); - } - - [TestMethod] - [Description("MCP initialization validates required hidden options even when explicit server and UI resource settings bypass documentation discovery.")] - public async Task When_RequiredCommandOptionIsHiddenAndUiResourceIsRegistered_Then_McpInitializationFailsClearly() - { - Func act = async () => - { - var fixture = await McpTestFixture.CreateAsync( - app => - { - app.Map( - "deploy", - ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) - .WithOption("internalToken", static option => option.Hidden()); - }, - configureOptions: options => - { - options.ServerName = "issue-76-test"; - options.UiResource("ui://issue-76/test", "Test"); - }).ConfigureAwait(false); - await fixture.DisposeAsync().ConfigureAwait(false); - }; - - var exception = await act.Should().ThrowAsync().ConfigureAwait(false); - exception.WithMessage("Option target 'internalToken' for command 'deploy' cannot be hidden because it is required."); - } - // ── Options group camelCase naming ───────────────────────────────── [TestMethod] diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index 24bc9f09..99d6737e 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -61,20 +61,26 @@ static string ( } [TestMethod] - [Description("A hidden option does not expose its enum values after the caller manually types its token.")] - public async Task When_HiddenEnumOptionAwaitsValue_Then_InteractiveAndShellCompletionReturnNoValues() + [Description("A hidden option does not expose its enum values even after the caller types its token by hand — probing must not confirm the option exists. The visible sibling is asserted in the same pass as a positive control: two bare BeEmpty assertions would also pass if this shape offered no values at all, and would then stay green with the visibility filter deleted.")] + public async Task When_HiddenEnumOptionAwaitsValue_Then_OnlyTheVisibleSiblingOffersValues() { var sut = CoreReplApp.Create(); sut.Map( "deploy", - static string ([ReplOption(Name = "secret-mode")] ProbeMode secretMode = ProbeMode.Debug) => secretMode.ToString()) + static string ( + [ReplOption(Name = "secret-mode")] ProbeMode secretMode = ProbeMode.Debug, + [ReplOption(Name = "denim-mode")] ProbeMode denimMode = ProbeMode.Debug) => $"{secretMode}{denimMode}") .WithOption("secretMode", static option => option.Hidden()); - var interactive = await ResolveAutocompleteAsync(sut, "deploy --secret-mode ").ConfigureAwait(false); - var shell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --secret-mode ").ConfigureAwait(false); + var hiddenInteractive = await ResolveAutocompleteAsync(sut, "deploy --secret-mode ").ConfigureAwait(false); + var hiddenShell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --secret-mode ").ConfigureAwait(false); + var visibleInteractive = await ResolveAutocompleteAsync(sut, "deploy --denim-mode ").ConfigureAwait(false); + var visibleShell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --denim-mode ").ConfigureAwait(false); - interactive.Suggestions.Select(static suggestion => suggestion.Value).Should().BeEmpty(); - shell.Should().BeEmpty(); + hiddenInteractive.Suggestions.Select(static suggestion => suggestion.Value).Should().BeEmpty(); + hiddenShell.Should().BeEmpty(); + visibleInteractive.Suggestions.Select(static suggestion => suggestion.Value).Should().Contain(nameof(ProbeMode.Debug)); + visibleShell.Should().Contain(nameof(ProbeMode.Debug)); } [TestMethod] From ba9bc7e88b0b1fbdd59092c797bb001bd39c26d8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 09:14:50 -0400 Subject: [PATCH 17/61] docs: document the option visibility axes, and that neither is access control - a matrix contrasting Hidden and AutomationHidden across help, completion, aggregate and targeted doc export, MCP schema, MCP tools/call, and parsing - an explicit warning that hiding is not access control. The worked examples are credential-shaped (InternalToken, internal-tenant), so the docs now say plainly that a hidden option stays invocable for anyone who knows its name and that privileged behavior needs real authorization - troubleshooting for an option that will not bind: it is almost always a target-name mistake, since WithOption takes the CLR name rather than the rendered token, and `doc export --json` is the way to see hidden options flagged - a note that nothing records *use* of a hidden option, so retiring a deprecated switch needs the handler to log it. Repl.Core cannot do this itself: its build fails on any package reference, so it has no logging abstraction available - option-level rows next to the existing command-level AutomationHidden rows in mcp-reference, mcp-overview and for-coding-agents --- docs/commands.md | 35 +++++++++++++++++++++++++++++++++++ docs/for-coding-agents.md | 1 + docs/mcp-overview.md | 1 + docs/mcp-reference.md | 2 ++ 4 files changed, 39 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 5405b953..50564725 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -99,6 +99,41 @@ A hidden option must be optional, because a discovery client would otherwise hav A fluent `.Hidden(isHidden: false)` still overrides `ReplOptionAttribute.Hidden` for an *optional* option. It cannot rescue a required one: `[ReplOption(Hidden = true)]` on a required option fails while the command is being mapped, before any fluent call can run. Fix the attribute instead. +> **Hiding is not access control.** A hidden option stays a fully invocable part of the command line for anyone who knows its name — nothing about it is authenticated, authorized, or secret. Use it for deprecated switches, diagnostic escape hatches and migration aliases. Gate privileged behavior with real authorization, never with obscurity. + +### Hiding an option from agents only + +`.AutomationHidden()` is the option-level counterpart of `CommandBuilder.AutomationHidden()`: it withholds the option from programmatic surfaces while leaving it fully visible to people. + +```csharp +app.Map("deploy", Deploy) + .WithOption("traceId", option => option.AutomationHidden()); +``` + +The declarative form is `[ReplOption(AutomationHidden = true)]`. It is **not** supported on typed global-options properties and fails fast there, because global options never reach a programmatic surface at all — the flag would have nothing to act on. `GlobalOptionBuilder` has no `AutomationHidden` for the same reason, enforced by its type rather than by a runtime check. + +The two axes are independent: + +| Surface | `.Hidden()` | `.AutomationHidden()` | +|---|---|---| +| Command help | omitted | **listed** | +| Interactive and shell completion | omitted | **offered** | +| Aggregate `doc export` | omitted | **exported, flagged** | +| `doc export ` (exact path) | **exported, flagged** | **exported, flagged** | +| MCP tool schema and prompt arguments | omitted | omitted | +| MCP `tools/call` | rejected | rejected | +| CLI and REPL parsing and binding | unaffected | unaffected | + +Both are discovery filters, and neither is an access-control boundary. + +### Troubleshooting an option that will not bind + +Almost always a target-name mistake. `WithOption` takes the **CLR** handler parameter or options-group property name, not the rendered `--option-name` token — `WithOption("internalMode", …)`, never `WithOption("internal-mode", …)`. An unknown target throws at configuration time and the message lists the targets that do exist, so read it rather than guessing. + +If the option is genuinely hidden and you want to confirm what the app thinks, export the command explicitly: `doc export --json` includes hidden options with `"isHidden": true`. The aggregate export omits them, so target the command. + +Note there is no built-in signal for *use* of a hidden option. If the point is retiring a deprecated switch, record that in the handler yourself — otherwise nothing will tell you when it has become safe to remove. + ### Accessing global options outside handlers Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers: diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 57964c1e..0e61fba8 100644 --- a/docs/for-coding-agents.md +++ b/docs/for-coding-agents.md @@ -144,6 +144,7 @@ Use these annotations to help agents make safer decisions: | `.OpenWorld()` | Talks to external systems; expect latency and failures. | | `.LongRunning()` | May take time; use call-now / poll-later patterns. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | +| `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. | Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP. diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index 3cdee8f6..9f3f42a3 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -45,6 +45,7 @@ Commands map to MCP primitives automatically: | `.AsPrompt()` | Prompt | Reusable instruction template | | `.AsMcpAppResource()` | Tool + `ui://` HTML resource | Interactive UI for capable hosts | | `.AutomationHidden()` | _(nothing)_ | Excluded from MCP entirely | +| `.WithOption(name, o => o.AutomationHidden())` | Tool without that option | Option people may use but agents should not | ## Annotations diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 5190316b..962da647 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -301,6 +301,8 @@ Notes: |---|---|---| | `.AutomationHidden()` | Per-command | Interactive-only commands | | `.Hidden()` | Per-command | Hidden from all surfaces | +| `.WithOption(name, o => o.AutomationHidden())` | Per-option | Option people may use but agents should not | +| `.WithOption(name, o => o.Hidden())` | Per-option | Deprecated or diagnostic switches | | `CommandFilter` | App-level | `o.CommandFilter = c => !c.Path.StartsWith("admin")` | | Module presence + `Programmatic` | Per-module | Entire feature areas | From 671a756efc70b3040157ad0f61add0148461cd39 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 09:37:53 -0400 Subject: [PATCH 18/61] test: cover yaml and xml documentation export The visibility flags are new members on ReplDocOption, a serialized public record, and the yaml and xml emitters serialize it wholesale rather than field by field. Only json and markdown had coverage, so those two formats would have broken silently. --- .../Given_DocumentationExport.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Repl.IntegrationTests/Given_DocumentationExport.cs b/src/Repl.IntegrationTests/Given_DocumentationExport.cs index 7684e59f..2b7c2919 100644 --- a/src/Repl.IntegrationTests/Given_DocumentationExport.cs +++ b/src/Repl.IntegrationTests/Given_DocumentationExport.cs @@ -81,6 +81,27 @@ public void When_ExportingExactCommandWithHiddenOption_Then_TheOptionIsIncludedA targeted.Text.Should().Contain("\"isHidden\": true"); } + [TestMethod] + [Description("The visibility flags are new members on a serialized public record, and yaml and xml emit that record wholesale rather than field by field. Only json and markdown had coverage, so this exercises the two formats that would have broken silently.")] + public void When_ExportingExactCommandAsYamlOrXml_Then_VisibilityFlagsSerialize() + { + var sut = ReplApp.Create() + .UseDocumentationExport(); + sut.Map( + "deploy", + ([ReplOption(Name = "environment")] string environment, [ReplOption(Name = "internalMode")] bool internalMode = false) => + $"{environment}:{internalMode}") + .WithOption("internalMode", static option => option.Hidden()); + + var yaml = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--yaml", "--no-logo"])); + var xml = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--xml", "--no-logo"])); + + yaml.ExitCode.Should().Be(0, yaml.Text); + yaml.Text.Should().Contain("internalMode"); + xml.ExitCode.Should().Be(0, xml.Text); + xml.Text.Should().Contain("internalMode"); + } + [TestMethod] [Description("Regression guard: verifies hidden context is explicitly targeted so exact-path export includes the hidden context metadata.")] public void When_ExportingExactHiddenContext_Then_HiddenContextIsIncluded() From 9e5b3f9989145a98c9ac4926115e5c3656f970d5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 10:29:06 -0400 Subject: [PATCH 19/61] =?UTF-8?q?fix:=20address=20the=20automated=20review?= =?UTF-8?q?=20=E2=80=94=20four=20real=20defects,=20one=20narrowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings reproduced before being fixed. **Required options whose arity cannot express it.** Arity counts the values a token consumes, so a non-nullable bool with no default reports ZeroOrOne while the binder still refuses to bind it when absent. Hiding one therefore produced an uninvocable command — proven: `exit=1, Unable to bind parameter 'force'`. The schema now carries CanBeOmitted and the guard consults it. Narrowed from what was reported: this only strands the command when the token is the sole way in. An OptionAndPositional parameter is still satisfiable positionally with its token hidden, and an existing completion test relies on exactly that, so the check is scoped to OptionOnly. **Automation-hidden options that cannot be omitted.** The projection withdrew the option from both the schema and the allow-list while leaving the command advertised, so every tools/call was guaranteed to fail. The command is now withdrawn from MCP instead, and its derived resources with it. Not rejected at configuration time, unlike the all-surfaces axis: the human command line remains perfectly usable, so forbidding the combination outright would ban a legitimate design. **Global token collisions.** GlobalOptionParser gives a colliding token to the LAST registration, and its own comment warns callers not to scan definitions independently — which the completion source did. A token registered as a visible option's alias and later as a hidden option's canonical form was advertised as visible while accepting it bound the hidden one. Visibility is now decided per token through TryResolveCustomGlobalDefinition, the helper that exists for this. **A documentation promise the code did not keep.** The troubleshooting section said the unknown-target diagnostic lists the registered targets. It did not. The exception now lists them, which is the more useful half of the fix, since a CLR-name mismatch is what that error almost always reports. --- src/Repl.Core/CommandBuilder.cs | 21 ++++++++++-- .../Internal/Options/OptionSchemaBuilder.cs | 12 ++++++- .../Internal/Options/OptionSchemaParameter.cs | 6 +++- .../Options/OptionTokenCompletionSource.cs | 29 +++++++++++++---- .../Given_Completions.cs | 32 +++++++++++++++++++ .../Given_HelpDiscovery.cs | 15 +++++++++ src/Repl.Mcp/McpAutomationProjection.cs | 26 ++++++++++++--- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 19 +++++++++++ 8 files changed, 144 insertions(+), 16 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 1ef34b84..07953d2a 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -182,11 +182,20 @@ private OptionBuilder SelectOption(string targetName) targetName = string.IsNullOrWhiteSpace(targetName) ? throw new ArgumentException("Option target name cannot be empty.", nameof(targetName)) : targetName; - if (!OptionSchema.TryGetParameter(targetName, out var parameter) + var schema = OptionSchema; + if (!schema.TryGetParameter(targetName, out var parameter) || parameter.Mode == ReplParameterMode.ArgumentOnly) { + // List the candidates: the target is the CLR parameter or property name, not the rendered + // token, and that mismatch is the mistake this exception almost always reports. + var candidates = schema.Parameters.Values + .Where(static candidate => candidate.Mode != ReplParameterMode.ArgumentOnly) + .Select(static candidate => candidate.Name) + .Order(StringComparer.Ordinal); + throw new KeyNotFoundException( - $"No option target named '{targetName}' is registered for command '{Route}'."); + $"No option target named '{targetName}' is registered for command '{Route}'. " + + $"Known option targets: {string.Join(", ", candidates)}."); } return new OptionBuilder( @@ -257,7 +266,13 @@ private void ValidateOptionVisibility(OptionSchema schema) continue; } - if (schema.ResolveParameterArity(parameter.Name) is ReplArity.ExactlyOne or ReplArity.OneOrMore) + // Two independent ways an option can be mandatory. Its token may demand a value — or its + // CLR shape may have nothing to fall back on when absent, which arity cannot express: a + // bool flag is ZeroOrOne yet a non-nullable bool with no default still fails to bind. + // The second case only strands the command when the token is the sole way in; an + // OptionAndPositional parameter can still be satisfied positionally with its token hidden. + if (schema.ResolveParameterArity(parameter.Name) is ReplArity.ExactlyOne or ReplArity.OneOrMore + || (!parameter.CanBeOmitted && parameter.Mode == ReplParameterMode.OptionOnly)) { // Name the rendered token as well as the CLR target: an operator greps argv for // '--internal-token' and would not find the parameter name anywhere. diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index bfee3fad..984458b3 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -122,7 +122,8 @@ private static void AppendParameterSchemaEntries( CaseSensitivity: optionAttribute?.CaseSensitivityOverride, ExplicitArity: optionAttribute?.ArityOverride, IsHidden: optionAttribute?.Hidden ?? false, - IsAutomationHidden: optionAttribute?.AutomationHidden ?? false); + IsAutomationHidden: optionAttribute?.AutomationHidden ?? false, + CanBeOmitted: CanParameterBeOmitted(parameter)); if (mode == ReplParameterMode.ArgumentOnly) { return; @@ -394,6 +395,15 @@ private static void ValidateOptionsGroupType( } } + // Mirrors the requiredness rule the documentation model reports: a value type with no default and + // no nullable wrapper has nothing to bind from when the option is absent, so the binder rejects + // it. Options-group properties always carry the default instance's value and so are never + // mandatory by shape — only an explicit arity can make them so. + private static bool CanParameterBeOmitted(ParameterInfo parameter) => + parameter.HasDefaultValue + || !parameter.ParameterType.IsValueType + || Nullable.GetUnderlyingType(parameter.ParameterType) is not null; + private static void AppendPropertySchemaEntries( PropertyInfo property, List entries, diff --git a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs index e2bbfe36..f1ff6cde 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs @@ -9,4 +9,8 @@ internal sealed record OptionSchemaParameter( ReplCaseSensitivity? CaseSensitivity = null, ReplArity? ExplicitArity = null, bool IsHidden = false, - bool IsAutomationHidden = false); + bool IsAutomationHidden = false, + // Whether a caller may leave the option out entirely. Distinct from Arity, which counts the + // values a token consumes: a bool flag is ZeroOrOne yet a non-nullable bool with no default + // still fails to bind when absent, so arity alone cannot answer "is this option mandatory". + bool CanBeOmitted = true); diff --git a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs index b59eb9fa..61b196f9 100644 --- a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs +++ b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs @@ -72,22 +72,37 @@ internal static void CollectGlobalOptionTokens( TryAddComposed("--output:", format, currentTokenPrefix, StringComparison.OrdinalIgnoreCase, dedupe, results); } + // Visibility is decided per TOKEN, not per definition: GlobalOptionParser gives a colliding + // token to the LAST registration, so a token owned by a visible definition here may in fact + // bind a hidden one. Its own comment warns callers not to scan definitions independently. foreach (var custom in options.Parsing.GlobalOptions.Values) { - if (custom.IsHidden) - { - continue; - } - - TryAdd(custom.CanonicalToken, currentTokenPrefix, comparison, dedupe, results); + TryAddGlobalToken(custom.CanonicalToken, options.Parsing, currentTokenPrefix, comparison, dedupe, results); foreach (var alias in custom.Aliases) { - TryAdd(alias, currentTokenPrefix, comparison, dedupe, results); + TryAddGlobalToken(alias, options.Parsing, currentTokenPrefix, comparison, dedupe, results); } } } + private static void TryAddGlobalToken( + string token, + ParsingOptions parsingOptions, + string currentTokenPrefix, + StringComparison comparison, + HashSet dedupe, + List results) + { + if (GlobalOptionParser.TryResolveCustomGlobalDefinition(token, parsingOptions, out var owner) + && owner.IsHidden) + { + return; + } + + TryAdd(token, currentTokenPrefix, comparison, dedupe, results); + } + /// Collects the route's discoverable option tokens matching the prefix. // Filters against the schema entries — not the flattened KnownTokens — so each option's // own case sensitivity is honored: an entry declared case-insensitive is offered for a diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index bfe62ada..d7f8d4fe 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -160,6 +160,38 @@ string[] Complete(string line) } } + [TestMethod] + [Description("A token registered as a visible option's alias and later as a hidden option's canonical form belongs, per GlobalOptionParser, to the LAST registration — the hidden one. Enumerating definitions independently would advertise the token from the visible definition while accepting it would bind the hidden one, which is the mismatch that file's own comment warns callers against.")] + public void When_AVisibleGlobalAliasCollidesWithALaterHiddenOption_Then_TheTokenIsNotSuggested() + { + var sut = ReplApp.Create() + .Options(options => + { + options.Parsing.AddGlobalOption("region", aliases: ["--tenant"]); + options.Parsing.AddGlobalOption("tenant"); + options.Parsing.GlobalOption("tenant").Hidden(); + }); + sut.Map("ping", () => "pong"); + const string line = "repl --t"; + + var output = ConsoleCaptureHelper.Capture(() => sut.Run( + [ + "completion", + "__complete", + "--shell", + "bash", + "--line", + line, + "--cursor", + line.Length.ToString(CultureInfo.InvariantCulture), + "--no-logo", + ])); + + output.ExitCode.Should().Be(0); + output.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Should().NotContain("--tenant"); + } + [TestMethod] [Description("Regression guard: verifies shell completion suggests custom global options so app-registered globals remain discoverable from tab completion.")] public void When_CompletingGlobalPrefix_Then_CustomGlobalOptionsAreSuggested() diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index a5f282af..294144f2 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -141,6 +141,21 @@ public void When_RequiredCommandOptionIsHiddenFluently_Then_TheHiddenCallItselfT .WithMessage("Option target 'internalToken' (rendered as '--internal-token') for command 'deploy' cannot be hidden because it is required. Either drop the Required/Arity constraint on the option, or hide a different one."); } + [TestMethod] + [Description("A non-nullable value-type option with no default cannot be omitted — the binder refuses to bind it — even though its token arity is only ZeroOrOne, because arity counts the values a token consumes rather than whether the option may be absent. Hiding it therefore produced a command no caller could invoke, which is exactly what the required-option guard promises to prevent.")] + public void When_HidingAnOptionThatCannotBeOmitted_Then_TheHiddenCallItselfThrows() + { + var sut = ReplApp.Create(); + var command = sut.Map( + "deploy", + ([ReplOption(Name = "force", Mode = ReplParameterMode.OptionOnly)] bool force) => force.ToString()); + + var act = () => command.WithOption("force", static option => option.Hidden()); + + act.Should().Throw() + .WithMessage("*'force'*cannot be hidden because it is required*"); + } + [TestMethod] [Description("The declarative form is rejected at the same phase as the fluent one: Map is where the option schema is built, so an attribute hiding a required option fails there instead of surviving until the first command runs.")] public void When_RequiredCommandOptionIsHiddenByAttribute_Then_MappingTheCommandThrows() diff --git a/src/Repl.Mcp/McpAutomationProjection.cs b/src/Repl.Mcp/McpAutomationProjection.cs index 8d023f04..bba144b6 100644 --- a/src/Repl.Mcp/McpAutomationProjection.cs +++ b/src/Repl.Mcp/McpAutomationProjection.cs @@ -3,7 +3,8 @@ namespace Repl.Mcp; /// -/// Removes automation-hidden options from a documentation model before anything MCP-facing reads it. +/// Removes automation-hidden options from a documentation model before anything MCP-facing reads it, +/// and withdraws any command that this would leave impossible to invoke. /// /// /// Applied once, at the model boundary, rather than at each MCP emitter. The generated tool schema @@ -20,17 +21,34 @@ internal static class McpAutomationProjection { public static ReplDocumentationModel Apply(ReplDocumentationModel model) { - if (!model.Commands.Any(static command => command.Options.Any(static option => option.IsAutomationHidden))) + if (!model.Commands.Any(HasAutomationHiddenOption)) { return model; } var projected = model.Commands - .Select(static command => command.Options.Any(static option => option.IsAutomationHidden) + .Where(static command => !command.Options.Any(static option => option.IsAutomationHidden && option.Required)) + .Select(static command => HasAutomationHiddenOption(command) ? command with { Options = [.. command.Options.Where(static option => !option.IsAutomationHidden)] } : command) .ToArray(); - return model with { Commands = projected }; + // Resources are projected from the command list, so a withdrawn command must not linger there. + var retained = projected + .Select(static command => command.Path) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return model with + { + Commands = projected, + Resources = [.. model.Resources.Where(resource => retained.Contains(resource.Path))], + }; } + + // Withholding an option a caller cannot omit leaves no valid invocation at all: the client cannot + // supply it and omitting it fails to bind, so every call would fail. Withdrawing the tool is + // preferable to advertising an impossible one — and unlike the all-surfaces Hidden axis this is + // not rejected at configuration time, because the human command line remains perfectly usable. + private static bool HasAutomationHiddenOption(ReplDocCommand command) => + command.Options.Any(static option => option.IsAutomationHidden); } diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index d936bb54..c67f23ac 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -592,6 +592,25 @@ public async Task When_CommandOptionIsAutomationHidden_Then_McpOmitsItAndRejects .Should().NotContain("denim", "the handler must not run for an argument the schema never advertised"); } + [TestMethod] + [Description("An automation-hidden option that cannot be omitted leaves no valid MCP invocation: the client cannot supply it, and omitting it fails to bind. Advertising such a tool guarantees every call fails, so the command is withdrawn from MCP instead — the human command line keeps working, which is why this is not rejected at configuration time the way an all-surfaces hidden required option is.")] + public async Task When_AutomationHiddenOptionCannotBeOmitted_Then_TheToolIsWithdrawn() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("ping", () => "pong"); + app.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken) + .WithOption("internalToken", static option => option.AutomationHidden()); + }); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + + tools.Should().Contain(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); + tools.Should().NotContain(tool => string.Equals(tool.Name, "deploy", StringComparison.Ordinal)); + } + [TestMethod] [Description("Hiding an option after the first tools/list must withdraw it from the next one. The MCP snapshot is rebuilt only when routing is invalidated, so a visibility change that forgets to invalidate leaves an agent seeing an option the app has retracted.")] public async Task When_OptionIsHiddenAfterFirstToolsList_Then_SecondToolsListOmitsIt() From d7656e711ad12291fe1c7438a6fad36ddb6bc1f0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 10:43:40 -0400 Subject: [PATCH 20/61] fix(help): decide global option visibility per token in help too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix covered the completion source but not help, which had the same defect: BuildGlobalOptionRows filtered GlobalOptions.Values by each definition's own IsHidden, while GlobalOptionParser gives a colliding token to the LAST registration. A token listed as a visible option's alias could therefore be advertised in help while accepting it bound a hidden option. Each canonical token and each alias is now resolved through TryResolveCustomGlobalDefinition, so a row keeps only the tokens that really belong to it. One funnel covers both consumers — the help model at HelpTextBuilder.cs:331, which markdown and json export read, and the text renderer at Rendering.cs:419. That leaves no site scanning global definitions independently: the completion source and this were the only two. --- .../Help/HelpTextBuilder.Rendering.cs | 19 ++++++++++++++--- .../Given_GlobalOptionsAccessor.cs | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 6b444272..b68d0000 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -589,14 +589,22 @@ private static string[][] BuildGlobalCommandRows(AmbientCommandOptions ambientOp private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) { ArgumentNullException.ThrowIfNull(parsingOptions); + + // Visibility is decided per TOKEN, not per definition: GlobalOptionParser gives a colliding + // token to the LAST registration, so a token listed under a visible definition here may in + // fact bind a hidden one. Its own comment warns callers not to scan definitions + // independently, which is why each canonical token and each alias is resolved separately. var customRows = parsingOptions.GlobalOptions.Values - .Where(option => !option.IsHidden) + .Where(option => IsGlobalTokenVisible(option.CanonicalToken, parsingOptions)) .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) .Select(option => { - var aliases = option.Aliases.Count == 0 + var visibleAliases = option.Aliases + .Where(alias => IsGlobalTokenVisible(alias, parsingOptions)) + .ToArray(); + var aliases = visibleAliases.Length == 0 ? string.Empty - : $", {string.Join(", ", option.Aliases)}"; + : $", {string.Join(", ", visibleAliases)}"; var description = string.IsNullOrWhiteSpace(option.Description) ? "Custom global option." : option.Description; @@ -610,6 +618,11 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) return [.. BuiltInGlobalOptionRows.Concat(customRows)]; } + // A token nobody claims is visible: built-in tokens never reach this resolver. + private static bool IsGlobalTokenVisible(string token, ParsingOptions parsingOptions) => + !GlobalOptionParser.TryResolveCustomGlobalDefinition(token, parsingOptions, out var owner) + || !owner.IsHidden; + private static TextTableStyle GetCommandRowsStyle(bool useAnsi, AnsiPalette palette) { if (!useAnsi) diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 47df630d..912935a9 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -22,6 +22,27 @@ public void When_GlobalOptionProvided_Then_HandlerCanReadItViaAccessor() output.Text.Should().Contain("acme"); } + [TestMethod] + [Description("Help renders one row per global option and lists that option's aliases, but GlobalOptionParser gives a colliding token to the LAST registration. A token listed as a visible option's alias can therefore bind a hidden option, so help must decide visibility per token rather than per definition — the same mismatch the completion source had.")] + public void When_AVisibleGlobalAliasCollidesWithALaterHiddenOption_Then_HelpDoesNotAdvertiseTheToken() + { + var sut = ReplApp.Create(); + sut.Options(options => + { + options.Parsing.AddGlobalOption("region", aliases: ["--tenant", "-r"]); + options.Parsing.AddGlobalOption("tenant"); + options.Parsing.GlobalOption("tenant").Hidden(); + }); + sut.Map("show", () => "ok"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + help.ExitCode.Should().Be(0); + help.Text.Should().Contain("--region", "the visible option keeps its own row"); + help.Text.Should().Contain("-r", "an alias with no collision stays listed"); + help.Text.Should().NotContain("--tenant", "accepting this token would bind the hidden option"); + } + [TestMethod] [Description("A manually registered hidden global option is omitted from help — token, alias, description and default alike — while staying bindable through the accessor. A visible sibling whose own token contains the hidden one's alias is registered on purpose: asserting the bare substring \"-t\" would pass only for as long as no other rendered token happened to contain it, so the assertions target the rendered option row instead.")] public void When_ManualGlobalOptionIsHidden_Then_HelpOmitsItsRowAndExplicitInvocationStillBinds() From 60d105e93999a8ae6c8d6c42d5e24779b0e06e98 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 11:29:32 -0400 Subject: [PATCH 21/61] fix: complete the visibility guarantees the review found still open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, each reproduced in red before being fixed. Two of them corrected earlier fixes of mine; one was a high finding from the original panel that I had simply never wired up. **Typo suggestions leaked hidden tokens.** InvocationOptionParser searched the full KnownTokens set, so mistyping `--secre` answered "Did you mean '--secret'?" for a hidden option — turning a validation error into a way to enumerate hidden options by probing at small edit distance. This was flagged as high in the original review and never addressed: the shared projection existed from the refactor, this one consumer was simply not pointed at it. OptionSchema gains DiscoverableTokens and the suggester reads that; parsing keeps the full set, so a hidden option still binds when supplied. **The required-option rule was wrong in both directions.** Two findings looked contradictory and together showed why: - an inferred ExactlyOne describes how many values a token consumes when present, not whether the option may be absent, so hiding a nullable reference option was rejected even though omitting it binds null happily - CanBeOmitted was scoped to OptionOnly on the theory that a positional fallback rescues the command. It does not for MCP, which reconstructs tool arguments as named tokens and cannot express a positional, so a non-defaulted bool in the default mode advertised a command whose every tool call failed to bind The rule is now: an explicitly declared required arity, or a CLR shape with nothing to fall back on, regardless of Mode. One existing test declared a bool flag with no default — the shape every other test avoids — and now supplies one. **Global help dropped rows it should have kept.** The previous per-token fix required the canonical token to survive, so a definition whose canonical form was claimed by a later hidden option lost its whole row even when an alias nobody took still reached it. Rows are rebuilt from whichever tokens the definition still owns, canonical or not. **Markdown did not flag visibility.** The exact-target export contract promises hidden options are included *and flagged*. Structured formats got that free by serializing the record; markdown formats each field by hand and rendered a hidden option indistinguishably from a public one. Options now carry [hidden] and [automation-hidden] markers, as commands already carry their own Hidden line. The sixth finding — per-token ownership in help — was already fixed in d7656e7; that review targeted the commit before it. --- src/Repl.Core/CommandBuilder.cs | 19 ++++--- .../Help/HelpTextBuilder.Rendering.cs | 33 ++++++------ .../Internal/Options/OptionSchema.cs | 13 +++++ .../Output/MarkdownOutputTransformer.cs | 15 ++++++ .../Parsing/InvocationOptionParser.cs | 4 +- .../Given_DocumentationExport.cs | 25 ++++++++++ .../Given_GlobalOptionsAccessor.cs | 21 ++++++++ .../Given_HelpDiscovery.cs | 50 +++++++++++++++++++ ...nteractiveAutocomplete_OptionCandidates.cs | 2 +- 9 files changed, 156 insertions(+), 26 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 07953d2a..2897a1f3 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -266,13 +266,18 @@ private void ValidateOptionVisibility(OptionSchema schema) continue; } - // Two independent ways an option can be mandatory. Its token may demand a value — or its - // CLR shape may have nothing to fall back on when absent, which arity cannot express: a - // bool flag is ZeroOrOne yet a non-nullable bool with no default still fails to bind. - // The second case only strands the command when the token is the sole way in; an - // OptionAndPositional parameter can still be satisfied positionally with its token hidden. - if (schema.ResolveParameterArity(parameter.Name) is ReplArity.ExactlyOne or ReplArity.OneOrMore - || (!parameter.CanBeOmitted && parameter.Mode == ReplParameterMode.OptionOnly)) + // Two independent ways an option can be mandatory, and each needs the right evidence. + // + // Only an EXPLICITLY declared arity counts: an inferred ExactlyOne describes how many + // values the token consumes when present, not whether the option may be absent, so a + // nullable reference parameter infers ExactlyOne yet binds null happily when omitted. + // + // The CLR shape is the other authority, and it holds regardless of Mode. A positional + // fallback does not rescue an OptionAndPositional parameter here: MCP reconstructs tool + // arguments as named tokens and cannot express a positional, so hiding a non-omittable + // one advertises a command whose every tool call fails to bind. + if (parameter.ExplicitArity is ReplArity.ExactlyOne or ReplArity.OneOrMore + || !parameter.CanBeOmitted) { // Name the rendered token as well as the CLR target: an operator greps argv for // '--internal-token' and would not find the parameter name anywhere. diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index b68d0000..cd9c52c5 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -593,27 +593,26 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) // Visibility is decided per TOKEN, not per definition: GlobalOptionParser gives a colliding // token to the LAST registration, so a token listed under a visible definition here may in // fact bind a hidden one. Its own comment warns callers not to scan definitions - // independently, which is why each canonical token and each alias is resolved separately. + // independently, so the row is rebuilt from whichever of its tokens it still owns — the + // canonical one carries no special weight, since a definition whose canonical token was + // claimed elsewhere may still be reachable through an alias nobody took. var customRows = parsingOptions.GlobalOptions.Values - .Where(option => IsGlobalTokenVisible(option.CanonicalToken, parsingOptions)) .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) - .Select(option => + .Select(option => new { - var visibleAliases = option.Aliases - .Where(alias => IsGlobalTokenVisible(alias, parsingOptions)) - .ToArray(); - var aliases = visibleAliases.Length == 0 - ? string.Empty - : $", {string.Join(", ", visibleAliases)}"; - var description = string.IsNullOrWhiteSpace(option.Description) + Option = option, + OwnedTokens = option.Aliases + .Prepend(option.CanonicalToken) + .Where(token => IsGlobalTokenVisible(token, parsingOptions)) + .ToArray(), + }) + .Where(static row => row.OwnedTokens.Length > 0) + .Select(static row => new[] + { + string.Join(", ", row.OwnedTokens), + string.IsNullOrWhiteSpace(row.Option.Description) ? "Custom global option." - : option.Description; - - return new[] - { - $"{option.CanonicalToken}{aliases}", - description, - }; + : row.Option.Description, }); return [.. BuiltInGlobalOptionRows.Concat(customRows)]; } diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 7439e0d7..b54b9cfe 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -55,6 +55,19 @@ .. Parameters.Values.Where(parameter => public IReadOnlyList DiscoverableEntries => _discoverableEntries ??= [.. Entries.Where(entry => !IsOptionHidden(entry.ParameterName))]; + private string[]? _discoverableTokens; + + /// + /// minus the tokens of hidden options. + /// + /// + /// Suggestions must read this, never : proposing "did you mean + /// '--secret'?" turns a validation error into a way to enumerate hidden options by probing at + /// small edit distance. Parsing keeps the full set, so a hidden option still binds when supplied. + /// + public IReadOnlyCollection DiscoverableTokens => + _discoverableTokens ??= [.. DiscoverableEntries.Select(entry => entry.Token).Distinct(StringComparer.Ordinal)]; + /// /// Whether the named parameter is hidden from discovery. Unknown names are not hidden: /// callers resolve tokens that may belong to route segments rather than options. diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index bdba66e0..1aa591d2 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -490,6 +490,21 @@ private static void AppendOptions(StringBuilder builder, IReadOnlyList internal sealed class McpInteractionChannel : IReplInteractionChannel { - private readonly IReadOnlyDictionary _prefillAnswers; + private readonly Dictionary _prefillAnswers; private readonly InteractivityMode _mode; private readonly McpServer? _server; private readonly ProgressToken? _progressToken; @@ -26,7 +26,7 @@ public McpInteractionChannel( ProgressToken? progressToken = null, IMcpFeedback? feedback = null) { - _prefillAnswers = prefillAnswers; + _prefillAnswers = new Dictionary(prefillAnswers, StringComparer.Ordinal); _mode = mode; _server = server; _progressToken = progressToken; @@ -40,7 +40,7 @@ public async ValueTask AskChoiceAsync( int? defaultIndex = null, AskOptions? options = null) { - if (_prefillAnswers.TryGetValue(name, out var prefill)) + if (TryGetPrefill(name, out var prefill)) { return ResolveChoiceIndex(prefill, choices); } @@ -77,7 +77,7 @@ public async ValueTask AskConfirmationAsync( bool? defaultValue = null, AskOptions? options = null) { - if (_prefillAnswers.TryGetValue(name, out var prefill)) + if (TryGetPrefill(name, out var prefill)) { return ParseBool(prefill); } @@ -113,7 +113,7 @@ public async ValueTask AskTextAsync( string? defaultValue = null, AskOptions? options = null) { - if (_prefillAnswers.TryGetValue(name, out var prefill)) + if (TryGetPrefill(name, out var prefill)) { return prefill; } @@ -149,7 +149,7 @@ public ValueTask AskSecretAsync( AskSecretOptions? options = null) { // Secrets: prefill ONLY, never elicitation or sampling (security). - if (_prefillAnswers.TryGetValue(name, out var prefill)) + if (TryGetPrefill(name, out var prefill)) { return ValueTask.FromResult(prefill); } @@ -166,7 +166,7 @@ public async ValueTask> AskMultiChoiceAsync( IReadOnlyList? defaultIndices = null, AskMultiChoiceOptions? options = null) { - if (_prefillAnswers.TryGetValue(name, out var prefill)) + if (TryGetPrefill(name, out var prefill)) { return ParseMultiChoice(prefill, choices, options); } @@ -538,6 +538,33 @@ private static int ResolveChoiceIndex(string value, IReadOnlyList choice $"Cannot resolve choice '{value}'. Available: {string.Join(", ", choices)}"); } + private bool TryGetPrefill(string name, out string prefill) + { + if (_prefillAnswers.TryGetValue(name, out prefill!)) + { + return true; + } + + var matches = _prefillAnswers + .Where(pair => string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matches.Length > 1) + { + throw new McpInteractionException( + $"Interactive prompt name '{name}' is ambiguous between prefills " + + $"{string.Join(", ", matches.Select(static pair => $"'{pair.Key}'"))}."); + } + + if (matches.Length == 1) + { + prefill = matches[0].Value; + return true; + } + + prefill = string.Empty; + return false; + } + private static bool ParseBool(string value) { if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 8c7d25c1..0d01152e 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -342,7 +342,9 @@ private static (List Tokens, Dictionary Prefills) Prepar foreach (var (requestedKey, value) in arguments) { - var key = allowedArgumentNames?.Resolve(requestedKey) ?? requestedKey; + var field = allowedArgumentNames?.Resolve(requestedKey) + ?? new AllowedArgumentField(requestedKey, McpArgumentFieldKind.Command); + var key = field.Name; if (!suppliedSchemaFields.Add(key)) { throw new InvalidOperationException( @@ -352,26 +354,25 @@ private static (List Tokens, Dictionary Prefills) Prepar ? value.GetString() ?? "" : value.GetRawText(); - if (key.StartsWith("answer.", StringComparison.Ordinal)) + switch (field.Kind) { - prefills[key["answer.".Length..]] = strValue; - } - else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.OrdinalIgnoreCase)) - { - ValidateResultCursor(strValue); - resultFlowTokens.Add(ReplResultFlowOptionNames.Cursor); - resultFlowTokens.Add(strValue); - } - else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.OrdinalIgnoreCase)) - { - ValidateResultPageSize(strValue); - resultFlowTokens.Add(ReplResultFlowOptionNames.PageSize); - resultFlowTokens.Add(strValue); - } - else - { - ValidateCommandArgumentValue(strValue); - stringArgs.Add(key, strValue); + case McpArgumentFieldKind.Answer: + prefills.Add(key["answer.".Length..], strValue); + break; + case McpArgumentFieldKind.Cursor: + ValidateResultCursor(strValue); + resultFlowTokens.Add(ReplResultFlowOptionNames.Cursor); + resultFlowTokens.Add(strValue); + break; + case McpArgumentFieldKind.PageSize: + ValidateResultPageSize(strValue); + resultFlowTokens.Add(ReplResultFlowOptionNames.PageSize); + resultFlowTokens.Add(strValue); + break; + default: + ValidateCommandArgumentValue(strValue); + stringArgs.Add(key, strValue); + break; } } @@ -418,56 +419,67 @@ private static AllowedArgumentNames BuildAllowedArgumentNames(ReplDocCommand com var names = new AllowedArgumentNames(); foreach (var argument in command.Arguments) { - names.Add(argument.Name); + names.Add(argument.Name, McpArgumentFieldKind.Command); } foreach (var option in command.Options) { - names.Add(option.Name); + names.Add(option.Name, McpArgumentFieldKind.Command); } if (command.Answers is { Count: > 0 }) { foreach (var answer in command.Answers) { - names.Add($"answer.{answer.Name}"); + names.Add($"answer.{answer.Name}", McpArgumentFieldKind.Answer); } } if (command.AcceptsPagingInput || command.EmitsPagedResult) { - names.Add(McpResultFlowArgumentNames.Cursor); - names.Add(McpResultFlowArgumentNames.PageSize); + names.Add(McpResultFlowArgumentNames.Cursor, McpArgumentFieldKind.Cursor); + names.Add(McpResultFlowArgumentNames.PageSize, McpArgumentFieldKind.PageSize); } return names; } + private enum McpArgumentFieldKind + { + Command, + Answer, + Cursor, + PageSize, + } + + private readonly record struct AllowedArgumentField(string Name, McpArgumentFieldKind Kind); + private sealed class AllowedArgumentNames { - private readonly HashSet _exactNames = new(StringComparer.Ordinal); - private readonly List _orderedNames = []; + private readonly Dictionary _exactFields = new(StringComparer.Ordinal); + private readonly List _orderedFields = []; - internal void Add(string name) + internal void Add(string name, McpArgumentFieldKind kind) { - if (!_exactNames.Add(name)) + var field = new AllowedArgumentField(name, kind); + if (!_exactFields.TryAdd(name, field)) { throw new InvalidOperationException( $"MCP argument name collision: '{name}' is declared more than once by the tool schema."); } - _orderedNames.Add(name); + _orderedFields.Add(field); } - internal string Resolve(string requestedName) + internal AllowedArgumentField Resolve(string requestedName) { - if (_exactNames.Contains(requestedName)) + if (_exactFields.TryGetValue(requestedName, out var exact)) { - return requestedName; + return exact; } - var matches = _orderedNames - .Where(name => string.Equals(name, requestedName, StringComparison.OrdinalIgnoreCase)) + var matches = _orderedFields + .Where(field => string.Equals(field.Name, requestedName, StringComparison.OrdinalIgnoreCase)) .ToArray(); return matches.Length switch { @@ -476,7 +488,7 @@ internal string Resolve(string requestedName) 1 => matches[0], _ => throw new InvalidOperationException( $"The MCP argument '{requestedName}' is ambiguous between schema fields " - + $"{string.Join(", ", matches.Select(static name => $"'{name}'"))}."), + + $"{string.Join(", ", matches.Select(static field => $"'{field.Name}'"))}."), }; } } diff --git a/src/Repl.McpTests/Given_McpInteractionChannel.cs b/src/Repl.McpTests/Given_McpInteractionChannel.cs index c8b86767..aa4b08a4 100644 --- a/src/Repl.McpTests/Given_McpInteractionChannel.cs +++ b/src/Repl.McpTests/Given_McpInteractionChannel.cs @@ -76,6 +76,37 @@ public async Task When_PrefillIsFalse_Then_ReturnsFalse() result.Should().BeFalse(); } + [TestMethod] + [Description("Runtime answer lookup preserves the historical case-insensitive contract when exactly one stored prefill matches.")] + public async Task When_RuntimeAnswerNameDiffersOnlyByCase_Then_UniquePrefillStillResolves() + { + var channel = CreateChannel(new Dictionary(StringComparer.Ordinal) { ["confirm"] = "false" }); + + var result = await channel.AskConfirmationAsync("CONFIRM", "Proceed?"); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Case-distinct prefill names resolve exactly, while a third non-exact casing is rejected as ambiguous instead of selecting an arbitrary answer.")] + public async Task When_CaseDistinctPrefillsExist_Then_ExactLookupWinsAndNonExactLookupIsAmbiguous() + { + var channel = CreateChannel(new Dictionary(StringComparer.Ordinal) + { + ["confirm"] = "false", + ["CONFIRM"] = "true", + }); + + var lower = await channel.AskConfirmationAsync("confirm", "Proceed?"); + var upper = await channel.AskConfirmationAsync("CONFIRM", "Proceed?"); + var ambiguous = () => channel.AskConfirmationAsync("Confirm", "Proceed?").AsTask(); + + lower.Should().BeFalse(); + upper.Should().BeTrue(); + await ambiguous.Should().ThrowAsync() + .WithMessage("*ambiguous*confirm*CONFIRM*"); + } + [TestMethod] [Description("Missing prefill with explicit default returns default even in PrefillThenFail mode.")] public async Task When_NoBoolPrefillInFailModeWithDefaultTrue_Then_ReturnsDefault() diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 83aa521e..537ce486 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -762,6 +762,67 @@ static string (string scope, [ReplOption(Name = "scope", Aliases = ["-s"])] stri faulted.WithMessage("*error occurred*"); } + [TestMethod] + [Description("A case-distinct ordinary option and the synthetic MCP cursor remain separate schema fields and bind to the option and paging context respectively in one real tool call.")] + public async Task When_OptionDiffersFromSyntheticCursorOnlyByCase_Then_EndToEndBindingKeepsBothDestinations() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map( + "inspect", + static string ( + [ReplOption(Name = "_replcursor")] string? ordinary, + IReplPagingContext paging) => $"{ordinary ?? "none"}:{paging.Cursor ?? "none"}")); + + var tool = (await fixture.Client.ListToolsAsync().ConfigureAwait(false)) + .Single(candidate => string.Equals(candidate.Name, "inspect", StringComparison.Ordinal)); + var result = await fixture.Client.CallToolAsync( + toolName: "inspect", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["_replcursor"] = "ordinary", + [McpResultFlowArgumentNames.Cursor] = "opaque", + }).ConfigureAwait(false); + var text = string.Join('\n', result.Content.OfType().Select(static block => block.Text)); + var properties = tool.JsonSchema.GetProperty("properties"); + + properties.TryGetProperty("_replcursor", out _).Should().BeTrue(); + properties.TryGetProperty(McpResultFlowArgumentNames.Cursor, out _).Should().BeTrue(); + result.IsError.Should().NotBeTrue(); + text.Should().Contain("ordinary:opaque"); + } + + [TestMethod] + [Description("A declared answer and a case-distinct ordinary option under the answer prefix remain separate destinations through schema generation, tool adaptation, and runtime interaction lookup.")] + public async Task When_OptionDiffersFromDeclaredAnswerOnlyByCase_Then_EndToEndBindingKeepsBothDestinations() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map( + "wizard", + static async Task ( + [ReplOption(Name = "answer.CONFIRM")] string? ordinary, + IReplInteractionChannel interaction) => + { + var answer = await interaction.AskConfirmationAsync("confirm", "Proceed?").ConfigureAwait(false); + return $"{ordinary ?? "none"}:{answer}"; + }) + .WithAnswer("confirm", "bool")); + + var tool = (await fixture.Client.ListToolsAsync().ConfigureAwait(false)) + .Single(candidate => string.Equals(candidate.Name, "wizard", StringComparison.Ordinal)); + var result = await fixture.Client.CallToolAsync( + toolName: "wizard", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["answer.CONFIRM"] = "ordinary", + ["answer.confirm"] = false, + }).ConfigureAwait(false); + var text = string.Join('\n', result.Content.OfType().Select(static block => block.Text)); + var properties = tool.JsonSchema.GetProperty("properties"); + + properties.TryGetProperty("answer.CONFIRM", out _).Should().BeTrue(); + properties.TryGetProperty("answer.confirm", out _).Should().BeTrue(); + result.IsError.Should().NotBeTrue(); + text.Should().Contain("ordinary:False"); + } + [TestMethod] [Description("MCP supplies IReplInteractionChannel, so the binder synthesizes structured progress before direct service lookup. AutomationHidden requiredness must use that same fallback, retain the tool, omit the field, and allow an argument-free call.")] public async Task When_AutomationHiddenRequiredProgressUsesInteractionChannel_Then_TheToolRemainsInvocable() diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index fd42bbff..2387c6be 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -341,6 +341,65 @@ public void When_NonExactMcpFieldMatchesMultipleCaseDistinctOptions_Then_Rejecte .WithMessage("*ambiguous*tenant*TENANT*"); } + [TestMethod] + [Description("Case-distinct ordinary fields retain command provenance beside exact synthetic cursor and page-size fields instead of being reclassified as result-flow controls.")] + public void When_CaseDistinctOptionsMatchResultFlowNames_Then_EachFieldKeepsItsOwnSink() + { + var command = new ReplDocCommand( + Path: "contacts", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: + [ + new ReplDocOption("_replcursor", "string", Required: false, Description: null, Aliases: ["--_replcursor"], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null), + new ReplDocOption("_replpagesize", "string", Required: false, Description: null, Aliases: ["--_replpagesize"], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null), + ], + AcceptsPagingInput: true); + + var (tokens, prefills) = McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("opaque"), + ["_replcursor"] = JsonSerializer.SerializeToElement("ordinary-cursor"), + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(25), + ["_replpagesize"] = JsonSerializer.SerializeToElement("ordinary-size"), + }); + + tokens.Should().Equal( + ReplResultFlowOptionNames.Cursor, "opaque", + ReplResultFlowOptionNames.PageSize, "25", + "contacts", "--_replcursor", "ordinary-cursor", "--_replpagesize", "ordinary-size"); + prefills.Should().BeEmpty(); + } + + [TestMethod] + [Description("A declared answer and a case-distinct ordinary option under the answer prefix keep separate prefill and CLI destinations.")] + public void When_CaseDistinctOptionMatchesAnswerField_Then_DeclaredProvenanceControlsDispatch() + { + var command = new ReplDocCommand( + Path: "wizard", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: [new ReplDocOption("answer.CONFIRM", "string", Required: false, Description: null, Aliases: ["--answer.CONFIRM"], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null)], + Answers: [new ReplDocAnswer("confirm", "bool", Description: null)]); + + var (tokens, prefills) = McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["answer.confirm"] = JsonSerializer.SerializeToElement(value: false), + ["answer.CONFIRM"] = JsonSerializer.SerializeToElement("ordinary"), + }); + + tokens.Should().Equal("wizard", "--answer.CONFIRM", "ordinary"); + prefills.Should().ContainSingle().Which.Should().Be(new KeyValuePair("confirm", "false")); + } + [TestMethod] [Description("PrepareExecution rejects token-like MCP argument values before reconstructing CLI tokens.")] public void When_ArgumentValueStartsWithDashDash_Then_Rejected() From ad388e4e70828aa5c7331b3b89a690c174022a58 Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 19:54:18 -0400 Subject: [PATCH 41/61] Hide parser-equivalent fluent aliases --- src/Repl.Core/CommandBuilder.cs | 17 +++++++- src/Repl.Core/CoreReplApp.cs | 6 ++- .../Internal/Options/OptionSchema.cs | 42 +++++++++++++++---- .../Internal/Options/OptionSchemaBuilder.cs | 2 +- .../Given_HelpDiscovery.cs | 23 ++++++++++ ...nteractiveAutocomplete_OptionCandidates.cs | 28 ++++++++++++- 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index a3557dab..2d523e09 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -22,6 +22,7 @@ public sealed class CommandBuilder // Derived discovery state (most visibly the MCP tool snapshot) is rebuilt only when routing // is invalidated, so any visibility change made after Map has to say so. private readonly Action? _invalidateRouting; + private readonly Func? _resolveOptionCaseSensitivity; /// /// Initializes a new instance of the class. @@ -31,11 +32,19 @@ public sealed class CommandBuilder /// /// Invoked when metadata that discovery surfaces derive from changes after registration. /// - internal CommandBuilder(string route, Delegate handler, Action? invalidateRouting = null) + /// + /// Resolves the current global option-token comparison mode for post-registration visibility changes. + /// + internal CommandBuilder( + string route, + Delegate handler, + Action? invalidateRouting = null, + Func? resolveOptionCaseSensitivity = null) { Route = route; Handler = handler; _invalidateRouting = invalidateRouting; + _resolveOptionCaseSensitivity = resolveOptionCaseSensitivity; SupportsHostedProtocolPassthrough = ComputeSupportsHostedProtocolPassthrough(handler); } @@ -259,7 +268,11 @@ private void UpdateOptionAliasVisibility(string targetName, string alias, bool i while (true) { var current = Volatile.Read(ref _optionSchema); - var candidate = current.WithAliasVisibility(targetName, alias, isHidden); + var candidate = current.WithAliasVisibility( + targetName, + alias, + isHidden, + _resolveOptionCaseSensitivity?.Invoke()); if (ReferenceEquals(Interlocked.CompareExchange(ref _optionSchema, candidate, current), current)) { _invalidateRouting?.Invoke(isHidden); diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index a19d2bfb..786d24a1 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -171,7 +171,11 @@ public CommandBuilder Map(string route, Delegate handler) : route; ArgumentNullException.ThrowIfNull(handler); - var command = new CommandBuilder(route, handler, InvalidateRouting); + var command = new CommandBuilder( + route, + handler, + InvalidateRouting, + () => _options.Parsing.OptionCaseSensitivity); ApplyMetadataFromAttributes(command, handler); var parsedTemplate = RouteTemplateParser.Parse(route, _options.Parsing); var template = InferRouteConstraintsFromHandler(parsedTemplate, handler); diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 8f2eda9f..0f827cff 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -3,16 +3,23 @@ namespace Repl.Internal.Options; internal sealed class OptionSchema { public static OptionSchema Empty { get; } = - new([], new Dictionary(StringComparer.OrdinalIgnoreCase)); + new( + [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + ReplCaseSensitivity.CaseSensitive); public OptionSchema( IReadOnlyList entries, - IReadOnlyDictionary parameters) + IReadOnlyDictionary parameters, + ReplCaseSensitivity globalCaseSensitivity) { Entries = entries; Parameters = parameters; + GlobalCaseSensitivity = globalCaseSensitivity; } + private ReplCaseSensitivity GlobalCaseSensitivity { get; } + public IReadOnlyList Entries { get; } public IReadOnlyDictionary Parameters { get; } @@ -98,13 +105,18 @@ public OptionSchema WithParameter(OptionSchemaParameter parameter) [parameter.Name] = parameter, }; - return new OptionSchema(Entries, parameters); + return new OptionSchema(Entries, parameters, GlobalCaseSensitivity); } - public OptionSchema WithAliasVisibility(string parameterName, string alias, bool isHidden) + public OptionSchema WithAliasVisibility( + string parameterName, + string alias, + bool isHidden, + ReplCaseSensitivity? currentGlobalCaseSensitivity = null) { - var displayToken = ResolveDisplayToken(parameterName); - if (string.Equals(displayToken, alias, StringComparison.Ordinal)) + var canonicalEntry = FindNamedEntry(parameterName); + if (canonicalEntry is not null + && TokensAreEquivalent(canonicalEntry, alias, currentGlobalCaseSensitivity)) { throw new ArgumentException( $"Token '{alias}' is the canonical token for option target '{parameterName}', not an alias.", @@ -115,7 +127,7 @@ public OptionSchema WithAliasVisibility(string parameterName, string alias, bool var entries = Entries.Select(entry => { if (!string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) - || !string.Equals(entry.Token, alias, StringComparison.Ordinal)) + || !TokensAreEquivalent(entry, alias, currentGlobalCaseSensitivity)) { return entry; } @@ -124,11 +136,25 @@ public OptionSchema WithAliasVisibility(string parameterName, string alias, bool return entry with { IsHidden = isHidden }; }).ToArray(); return found - ? new OptionSchema(entries, Parameters) + ? new OptionSchema(entries, Parameters, GlobalCaseSensitivity) : throw new KeyNotFoundException( $"No alias token '{alias}' is registered for option target '{parameterName}'."); } + private bool TokensAreEquivalent( + OptionSchemaEntry entry, + string token, + ReplCaseSensitivity? currentGlobalCaseSensitivity) + { + var effectiveCaseSensitivity = entry.CaseSensitivity + ?? currentGlobalCaseSensitivity + ?? GlobalCaseSensitivity; + var comparison = effectiveCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(entry.Token, token, comparison); + } + public IReadOnlyList ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity) { if (string.IsNullOrWhiteSpace(token)) diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 5a55b6e2..4347b130 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -55,7 +55,7 @@ public static OptionSchema Build( ValidatePositionalBindingCompatibility(regularPositionalParameterNames, groupPositionalPropertyNames); ValidateTokenCollisions(entries, parsingOptions, template); - return new OptionSchema(entries, parameters); + return new OptionSchema(entries, parameters, parsingOptions.OptionCaseSensitivity); } private static bool ShouldSkipSchemaParameter( diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 4d30f9a4..eb7e638a 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -133,6 +133,29 @@ static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"] option.Aliases.Should().Contain(["--tenant", "--ACCOUNT"]); option.Aliases.Should().NotContain("--account"); } + [TestMethod] + [Description("Fluent alias visibility follows a route option's case-insensitive override: parser-equivalent aliases are all hidden from help and documentation but remain accepted by parsing under a case-sensitive global default.")] + public void When_FluentAliasesUseCaseInsensitiveOverride_Then_AllEquivalentSpellingsAreHiddenFromDiscovery() + { + var sut = ReplApp.Create(); + sut.Map( + "publish", + static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"], CaseSensitivity = ReplCaseSensitivity.CaseInsensitive)] string? tenant = null) => tenant ?? "none") + .WithOption("tenant", static option => option.HiddenAlias("--account")); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--help", "--no-logo"])); + var lower = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--account", "south", "--no-logo"])); + var upper = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--ACCOUNT", "north", "--no-logo"])); + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + help.Text.Should().Contain("--tenant").And.NotContain("--account").And.NotContain("--ACCOUNT"); + option.Aliases.Should().Contain("--tenant").And.NotContain(["--account", "--ACCOUNT"]); + lower.ExitCode.Should().Be(0, lower.Text); + lower.Text.Should().Contain("south"); + upper.ExitCode.Should().Be(0, upper.Text); + upper.Text.Should().Contain("north"); + } + [TestMethod] [Description("A manually registered global alias can be hidden independently: root help omits only that alias while the pre-routing parser still accepts it and exposes its value through the global accessor.")] public void When_GlobalAliasIsHidden_Then_HelpOmitsItButParsingRetainsIt() diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index e032237c..7ef75e43 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -118,6 +118,31 @@ static string ([ReplOption( shellValues.Should().BeEmpty(); } + [TestMethod] + [Description("Fluent hiding uses the option's effective global case-insensitive comparer, so all parser-equivalent alias spellings disappear from option-name and value completion while the canonical token stays visible.")] + public async Task When_FluentAliasHasCaseEquivalentInInsensitiveMode_Then_AllEquivalentSpellingsAreHiddenFromCompletion() + { + var sut = CoreReplApp.Create(); + var command = sut.Map( + "deploy", + static string ([ReplOption(Name = "mode", Aliases = ["--legacy-mode", "--LEGACY-MODE"])] ProbeMode mode = ProbeMode.Debug) => mode.ToString()); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + command.WithOption("mode", static option => option.HiddenAlias("--legacy-mode")); + + var interactiveNames = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); + var shellNames = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --").ConfigureAwait(false); + var hiddenInteractiveValues = await ResolveAutocompleteAsync(sut, "deploy --LEGACY-MODE ").ConfigureAwait(false); + var hiddenShellValues = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app deploy --LEGACY-MODE ").ConfigureAwait(false); + var canonicalInteractiveValues = await ResolveAutocompleteAsync(sut, "deploy --mode ").ConfigureAwait(false); + + interactiveNames.Suggestions.Select(static suggestion => suggestion.Value) + .Should().Contain("--mode").And.NotContain(["--legacy-mode", "--LEGACY-MODE"]); + shellNames.Should().Contain("--mode").And.NotContain(["--legacy-mode", "--LEGACY-MODE"]); + hiddenInteractiveValues.Suggestions.Should().BeEmpty(); + hiddenShellValues.Should().BeEmpty(); + canonicalInteractiveValues.Suggestions.Select(static suggestion => suggestion.Value).Should().Contain(nameof(ProbeMode.Debug)); + } + [TestMethod] [Description("A hidden option does not expose its enum values even after the caller types its token by hand — probing must not confirm the option exists. The visible sibling is asserted in the same pass as a positive control: two bare BeEmpty assertions would also pass if this shape offered no values at all, and would then stay green with the visibility filter deleted.")] public async Task When_HiddenEnumOptionAwaitsValue_Then_OnlyTheVisibleSiblingOffersValues() @@ -554,7 +579,8 @@ public void When_SchemaEntryIsCaseInsensitive_Then_DifferentlyCasedPrefixStillMa }; var schema = new Repl.Internal.Options.OptionSchema( entries, - new Dictionary(StringComparer.OrdinalIgnoreCase)); + new Dictionary(StringComparer.OrdinalIgnoreCase), + ReplCaseSensitivity.CaseSensitive); var results = new List(); Repl.Internal.Options.OptionTokenCompletionSource.CollectRouteOptionTokens( From 73feafa0aa75de82e935eb2d35bdd53a0c6c0f7c Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 19:57:02 -0400 Subject: [PATCH 42/61] Cover case-varied MCP answer prefills --- src/Repl.Mcp/McpInteractionChannel.cs | 3 ++- .../Given_McpInteractionChannel.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 893e8683..ab6531ad 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -540,8 +540,9 @@ private static int ResolveChoiceIndex(string value, IReadOnlyList choice private bool TryGetPrefill(string name, out string prefill) { - if (_prefillAnswers.TryGetValue(name, out prefill!)) + if (_prefillAnswers.TryGetValue(name, out var exactPrefill)) { + prefill = exactPrefill; return true; } diff --git a/src/Repl.McpTests/Given_McpInteractionChannel.cs b/src/Repl.McpTests/Given_McpInteractionChannel.cs index aa4b08a4..f1a5a355 100644 --- a/src/Repl.McpTests/Given_McpInteractionChannel.cs +++ b/src/Repl.McpTests/Given_McpInteractionChannel.cs @@ -107,6 +107,29 @@ await ambiguous.Should().ThrowAsync() .WithMessage("*ambiguous*confirm*CONFIRM*"); } + [TestMethod] + [Description("Every interaction kind uses the shared exact-first, unique case-insensitive prefill fallback when an ordinal dictionary contains one differently-cased answer name.")] + public async Task When_AllRuntimeAnswerKindsDifferOnlyByCase_Then_UniquePrefillsStillResolve() + { + var channel = CreateChannel(new Dictionary(StringComparer.Ordinal) + { + ["color"] = "green", + ["name"] = "Alice", + ["password"] = "s3cret", + ["tags"] = "red,blue", + }); + + var choice = await channel.AskChoiceAsync("COLOR", "Pick a color", ["red", "green", "blue"]); + var text = await channel.AskTextAsync("NAME", "Enter name"); + var secret = await channel.AskSecretAsync("PASSWORD", "Enter password"); + var multiChoice = await channel.AskMultiChoiceAsync("TAGS", "Select tags", ["red", "green", "blue"]); + + choice.Should().Be(1); + text.Should().Be("Alice"); + secret.Should().Be("s3cret"); + multiChoice.Should().BeEquivalentTo([0, 2]); + } + [TestMethod] [Description("Missing prefill with explicit default returns default even in PrefillThenFail mode.")] public async Task When_NoBoolPrefillInFailModeWithDefaultTrue_Then_ReturnsDefault() From 08383557d17fe94694a2c080fe16ec14d5b1faaa Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 20:01:14 -0400 Subject: [PATCH 43/61] Publish snapshot retractions atomically --- src/Repl.Mcp/McpServerHandler.cs | 45 +++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 9a6467d3..131b0070 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -37,9 +37,8 @@ internal sealed class McpServerHandler private readonly Lock _attachLock = new(); private McpGeneratedSnapshot? _snapshot; - private long _snapshotVersion = 1; + private SnapshotVersionState _snapshotState = new(Version: 1, LastVisibilityRetractionVersion: 0); private long _builtSnapshotVersion; - private long _lastVisibilityRetractionVersion; private McpServer? _server; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; @@ -306,7 +305,7 @@ private async ValueTask GetSnapshotAsync( { AttachServer(server); - var snapshotVersion = Volatile.Read(ref _snapshotVersion); + var snapshotVersion = Volatile.Read(ref _snapshotState).Version; if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion && _snapshot is { } cached) { @@ -316,7 +315,7 @@ private async ValueTask GetSnapshotAsync( await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - snapshotVersion = Volatile.Read(ref _snapshotVersion); + snapshotVersion = Volatile.Read(ref _snapshotState).Version; if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion && _snapshot is { } refreshed) { @@ -340,7 +339,8 @@ private async ValueTask GetSnapshotAsync( } catch (Exception) when ( previousSnapshot is not null - && Volatile.Read(ref _lastVisibilityRetractionVersion) <= Volatile.Read(ref _builtSnapshotVersion)) + && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion + <= Volatile.Read(ref _builtSnapshotVersion)) { // Preserve availability for transient projection failures, but leave the version dirty // so the next request retries without requiring another routing mutation. @@ -363,19 +363,19 @@ private async ValueTask BuildCurrentSnapshotAsync( cancellationToken.ThrowIfCancellationRequested(); await _roots.GetAsync(cancellationToken).ConfigureAwait(false); var built = BuildSnapshotCore(); - var observedVersion = Volatile.Read(ref _snapshotVersion); + var observedState = Volatile.Read(ref _snapshotState); - // Visibility was retracted after this projection captured its input model. Publishing - // the otherwise-successful result would disclose the hidden field once to the request - // already in flight, so rebuild under the same gate against the newer version. - if (Volatile.Read(ref _lastVisibilityRetractionVersion) > snapshotVersion) + // Version and retraction watermark are one atomically published state. A reader can + // therefore never observe the new version without the visibility retraction that caused it. + // If that state appeared after projection started, discard the result and rebuild. + if (observedState.LastVisibilityRetractionVersion > snapshotVersion) { - snapshotVersion = observedVersion; + snapshotVersion = observedState.Version; continue; } _snapshot = built; - if (observedVersion == snapshotVersion) + if (observedState.Version == snapshotVersion) { Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); } @@ -470,6 +470,10 @@ private void AttachServer(McpServer? server) } } + private sealed record SnapshotVersionState( + long Version, + long LastVisibilityRetractionVersion); + private void EnsureRoutingSubscription() { if (_routingChangedHandler is not null || _app is not CoreReplApp coreApp) @@ -517,11 +521,22 @@ private void EnsureRootsNotificationHandler(McpServer server) private void OnRoutingInvalidated(bool isVisibilityRetraction) { - var invalidatedVersion = Interlocked.Increment(ref _snapshotVersion); - if (isVisibilityRetraction) + SnapshotVersionState currentState; + SnapshotVersionState invalidatedState; + do { - Interlocked.Exchange(ref _lastVisibilityRetractionVersion, invalidatedVersion); + currentState = Volatile.Read(ref _snapshotState); + var invalidatedVersion = currentState.Version + 1; + invalidatedState = new SnapshotVersionState( + invalidatedVersion, + isVisibilityRetraction + ? invalidatedVersion + : currentState.LastVisibilityRetractionVersion); } + while (!ReferenceEquals( + Interlocked.CompareExchange(ref _snapshotState, invalidatedState, currentState), + currentState)); + if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) { Interlocked.Exchange(ref _compatibilityIntroServed, 0); From eafe8f4b8479707987d6e6df06964f63cad82037 Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 20:19:23 -0400 Subject: [PATCH 44/61] Cover fluent alias visibility reversals --- .../Given_HelpDiscovery.cs | 48 +++++++++++++++++ ...nteractiveAutocomplete_OptionCandidates.cs | 52 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index eb7e638a..fb7bd20d 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -156,6 +156,54 @@ static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"] upper.Text.Should().Contain("north"); } + [TestMethod] + [Description("Unhiding through an opposite-case alias under the effective case-insensitive comparer restores every parser-equivalent spelling to help and documentation while both remain parsable.")] + public void When_FluentAliasIsUnhiddenThroughEquivalentCasing_Then_AllEquivalentSpellingsReturnToDiscovery() + { + var sut = ReplApp.Create(); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + var command = sut.Map( + "publish", + static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"])] string? tenant = null) => tenant ?? "none"); + command.WithOption("tenant", static option => + { + option.HiddenAlias("--account"); + option.HiddenAlias("--ACCOUNT", isHidden: false); + }); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--help", "--no-logo"])); + var lower = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--account", "south", "--no-logo"])); + var upper = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--ACCOUNT", "north", "--no-logo"])); + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + help.Text.Should().Contain("--account"); + option.Aliases.Should().Contain("--account"); + lower.ExitCode.Should().Be(0, lower.Text); + upper.ExitCode.Should().Be(0, upper.Text); + } + + [TestMethod] + [Description("A per-option case-sensitive override wins over a case-insensitive global mode, so hiding one exact fluent alias leaves its case-distinct sibling discoverable and both exact spellings parsable.")] + public void When_FluentAliasUsesSensitiveOverrideUnderInsensitiveGlobal_Then_OnlyExactSpellingIsHiddenFromDiscovery() + { + var sut = ReplApp.Create(); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + sut.Map( + "publish", + static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"], CaseSensitivity = ReplCaseSensitivity.CaseSensitive)] string? tenant = null) => tenant ?? "none") + .WithOption("tenant", static option => option.HiddenAlias("--account")); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--help", "--no-logo"])); + var lower = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--account", "south", "--no-logo"])); + var upper = ConsoleCaptureHelper.Capture(() => sut.Run(["publish", "--ACCOUNT", "north", "--no-logo"])); + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + help.Text.Should().NotContain("--account").And.Contain("--ACCOUNT"); + option.Aliases.Should().NotContain("--account").And.Contain("--ACCOUNT"); + lower.ExitCode.Should().Be(0, lower.Text); + upper.ExitCode.Should().Be(0, upper.Text); + } + [TestMethod] [Description("A manually registered global alias can be hidden independently: root help omits only that alias while the pre-routing parser still accepts it and exposes its value through the global accessor.")] public void When_GlobalAliasIsHidden_Then_HelpOmitsItButParsingRetainsIt() diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index 7ef75e43..6c181892 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -143,6 +143,58 @@ public async Task When_FluentAliasHasCaseEquivalentInInsensitiveMode_Then_AllEqu canonicalInteractiveValues.Suggestions.Select(static suggestion => suggestion.Value).Should().Contain(nameof(ProbeMode.Debug)); } + [TestMethod] + [Description("Unhiding through an opposite-case alias under a case-insensitive comparer restores all equivalent aliases and their values to both interactive and shell completion.")] + public async Task When_FluentAliasIsUnhiddenThroughEquivalentCasing_Then_AllEquivalentSpellingsReturnToCompletion() + { + var sut = CoreReplApp.Create(); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + var command = sut.Map( + "deploy", + static string ([ReplOption(Name = "mode", Aliases = ["--legacy-mode", "--LEGACY-MODE"])] ProbeMode mode = ProbeMode.Debug) => mode.ToString()); + command.WithOption("mode", static option => + { + option.HiddenAlias("--legacy-mode"); + option.HiddenAlias("--LEGACY-MODE", isHidden: false); + }); + + var interactiveNames = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); + var shell = new ShellCompletionEngine(sut); + var shellNames = await ResolveShellCandidatesAsync(shell, "app deploy --").ConfigureAwait(false); + var lowerValues = await ResolveAutocompleteAsync(sut, "deploy --legacy-mode ").ConfigureAwait(false); + var upperValues = await ResolveShellCandidatesAsync(shell, "app deploy --LEGACY-MODE ").ConfigureAwait(false); + + interactiveNames.Suggestions.Select(static suggestion => suggestion.Value) + .Should().Contain("--legacy-mode"); + shellNames.Should().Contain("--legacy-mode"); + lowerValues.Suggestions.Select(static suggestion => suggestion.Value).Should().Contain(nameof(ProbeMode.Debug)); + upperValues.Should().Contain(nameof(ProbeMode.Debug)); + } + + [TestMethod] + [Description("A case-sensitive option override wins over the case-insensitive global mode in fluent visibility and completion: only the exact hidden alias and its values disappear.")] + public async Task When_FluentAliasUsesSensitiveOverrideUnderInsensitiveGlobal_Then_OnlyExactSpellingIsHiddenFromCompletion() + { + var sut = CoreReplApp.Create(); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + sut.Map( + "deploy", + static string ([ReplOption(Name = "mode", Aliases = ["--legacy-mode", "--LEGACY-MODE"], CaseSensitivity = ReplCaseSensitivity.CaseSensitive)] ProbeMode mode = ProbeMode.Debug) => mode.ToString()) + .WithOption("mode", static option => option.HiddenAlias("--legacy-mode")); + + var interactiveNames = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); + var shell = new ShellCompletionEngine(sut); + var shellNames = await ResolveShellCandidatesAsync(shell, "app deploy --").ConfigureAwait(false); + var hiddenValues = await ResolveAutocompleteAsync(sut, "deploy --legacy-mode ").ConfigureAwait(false); + var visibleValues = await ResolveShellCandidatesAsync(shell, "app deploy --LEGACY-MODE ").ConfigureAwait(false); + + interactiveNames.Suggestions.Select(static suggestion => suggestion.Value) + .Should().Contain("--LEGACY-MODE").And.NotContain("--legacy-mode"); + shellNames.Should().Contain("--LEGACY-MODE").And.NotContain("--legacy-mode"); + hiddenValues.Suggestions.Should().BeEmpty(); + visibleValues.Should().Contain(nameof(ProbeMode.Debug)); + } + [TestMethod] [Description("A hidden option does not expose its enum values even after the caller types its token by hand — probing must not confirm the option exists. The visible sibling is asserted in the same pass as a positive control: two bare BeEmpty assertions would also pass if this shape offered no values at all, and would then stay green with the visibility filter deleted.")] public async Task When_HiddenEnumOptionAwaitsValue_Then_OnlyTheVisibleSiblingOffersValues() From 19797895113a7518c9220f100c52d1471827715e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 20:19:36 -0400 Subject: [PATCH 45/61] fix: close bool-option value re-lexing into hidden aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool call could supply a value for a visible bool option that itself looked like an option token (e.g. "-t=denim", an alias of a Hidden() option on the same route). ApplyBoolFlagValue declines to consume such a value with no diagnostic — required so legitimate flag-chaining like "--verbose --other" keeps working — leaving it dangling to be re-lexed as a fresh option on the parser's next iteration, silently binding the hidden target. Every other option kind either has no value to smuggle or fails the whole call via a diagnostic when its value looks option-like, so only bool options need protecting: their value is now embedded as a single inline "--name=value" token, which cannot be split apart and re-lexed. Also close the analogous route-segment vector: a positional argument value has no inline-token escape available, so a value that itself looks like an option token is rejected outright when substituted into the CLI stream. --- .../Parsing/InvocationOptionParser.cs | 6 +- src/Repl.Mcp/McpToolAdapter.cs | 56 +++++++- src/Repl.McpTests/Given_McpToolAdapter.cs | 120 ++++++++++++++++++ 3 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/Repl.Core/Parsing/InvocationOptionParser.cs b/src/Repl.Core/Parsing/InvocationOptionParser.cs index 78a6e76e..672a8143 100644 --- a/src/Repl.Core/Parsing/InvocationOptionParser.cs +++ b/src/Repl.Core/Parsing/InvocationOptionParser.cs @@ -210,7 +210,11 @@ public static OptionParsingResult Parse( return new OptionParsingResult(readonlyNamedOptions, positionalArguments, diagnostics); } - private static bool LooksLikeOptionToken(string token) => + // Internal (not private): the MCP adapter reuses this to reject route-segment argument + // values that would be re-lexed as an option token once substituted into the CLI stream, + // since a positional segment has no separator that can escape it the way an inline + // "--token=value" pair does for a named option. + internal static bool LooksLikeOptionToken(string token) => token.Length >= 2 && token[0] == '-'; // Shared with the completion engines: a signed numeric literal (-42) is a positional diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 0d01152e..cfe07e61 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Repl; using Repl.Documentation; using Repl.Interaction; @@ -326,14 +327,26 @@ internal static (List Tokens, Dictionary Prefills) Prepa static option => option.Name, static option => option.Aliases.Count > 0 ? option.Aliases[0] : $"--{option.Name}", StringComparer.Ordinal); - return PrepareExecution(command.Path, arguments, allowedArgumentNames, optionTokens); + // A bool-flag option's value token is only ever consumed on a best-effort basis: when it + // looks like a fresh option token, ApplyBoolFlagValue declines it WITHOUT a diagnostic + // (that decline is required so legitimate flag-chaining like "--verbose --other" keeps + // working), leaving it to be re-lexed as its own token on the parser's next iteration — + // which can bind a Hidden() option's alias. Every other option kind either has no value to + // smuggle or fails the whole call with a diagnostic when its value looks option-like, so + // only bool options need the inline "--name=value" form that makes re-lexing impossible. + var boolOptionNames = command.Options + .Where(static option => string.Equals(option.Type, "bool", StringComparison.Ordinal)) + .Select(static option => option.Name) + .ToHashSet(StringComparer.Ordinal); + return PrepareExecution(command.Path, arguments, allowedArgumentNames, optionTokens, boolOptionNames); } private static (List Tokens, Dictionary Prefills) PrepareExecution( string routePath, IDictionary arguments, AllowedArgumentNames? allowedArgumentNames, - IReadOnlyDictionary optionTokens) + IReadOnlyDictionary optionTokens, + IReadOnlySet? boolOptionNames = null) { var stringArgs = new Dictionary(StringComparer.Ordinal); var prefills = new Dictionary(StringComparer.Ordinal); @@ -376,7 +389,7 @@ private static (List Tokens, Dictionary Prefills) Prepar } } - var tokens = ReconstructTokens(routePath, stringArgs, optionTokens); + var tokens = ReconstructTokens(routePath, stringArgs, optionTokens, boolOptionNames); tokens.InsertRange(0, resultFlowTokens); return (tokens, prefills); } @@ -499,12 +512,13 @@ internal AllowedArgumentField Resolve(string requestedName) internal static List ReconstructTokens( string routePath, IDictionary arguments) => - ReconstructTokens(routePath, arguments, optionTokens: null); + ReconstructTokens(routePath, arguments, optionTokens: null, boolOptionNames: null); private static List ReconstructTokens( string routePath, IDictionary arguments, - IReadOnlyDictionary? optionTokens) + IReadOnlyDictionary? optionTokens, + IReadOnlySet? boolOptionNames) { var tokens = new List(); var consumedArgs = new HashSet( @@ -522,6 +536,7 @@ private static List ReconstructTokens( var strValue = value.ToString() ?? ""; if (strValue.Length > 0) { + ValidatePositionalArgumentValue(strValue); tokens.Add(strValue); } @@ -541,14 +556,41 @@ private static List ReconstructTokens( { if (!consumedArgs.Contains(key)) { - tokens.Add(ResolveOptionToken(key, optionTokens)); - tokens.Add(value?.ToString() ?? ""); + var token = ResolveOptionToken(key, optionTokens); + var strValue = value?.ToString() ?? ""; + if (boolOptionNames?.Contains(key) == true) + { + // Single inline token: TrySplitOptionToken (InvocationOptionParser) splits on + // the first '=' only, so the value survives intact even when it starts with + // '-' or contains '=' itself, and it can never be left dangling by + // ApplyBoolFlagValue to be re-lexed as a fresh option on the next iteration. + tokens.Add($"{token}={strValue}"); + } + else + { + tokens.Add(token); + tokens.Add(strValue); + } } } return tokens; } + // Guards the ONE place an MCP-supplied value becomes a bare CLI token: a positional route + // segment. It has no separator that can escape the value the way an inline "--token=value" + // pair does for a bool option, so a value that would itself be lexed as an option token (and + // could then resolve to a route/global option — including a Hidden() one, since parsing still + // accepts those — via the general-purpose parser) must be rejected here instead. + private static void ValidatePositionalArgumentValue(string value) + { + if (InvocationOptionParser.LooksLikeOptionToken(value) && !InvocationOptionParser.IsSignedNumericLiteral(value)) + { + throw new InvalidOperationException( + "The MCP argument value cannot start like a CLI option because it fills a positional route segment, which has no way to escape it."); + } + } + private static string ResolveOptionToken(string key, IReadOnlyDictionary? optionTokens) { if (optionTokens is null) diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 2387c6be..0774af78 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -1,5 +1,8 @@ +using Repl; using Repl.Mcp; using Repl.Documentation; +using Repl.Internal.Options; +using Repl.Parameters; using System.Text.Json; namespace Repl.McpTests; @@ -469,6 +472,123 @@ public void When_ResultFlowInputIsNotInToolSchema_Then_Rejected() .WithMessage("*not defined*schema*"); } + [TestMethod] + [Description("A bool option's value is embedded as a single inline '--name=value' token instead of a '--name' / 'value' pair, so it can never be split apart and re-lexed as a fresh option by the downstream parser.")] + public void When_BoolOptionValueIsReconstructed_Then_EmbeddedAsSingleInlineToken() + { + var command = new ReplDocCommand( + Path: "deploy", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: [new ReplDocOption("verbose", "bool", Required: false, Description: null, Aliases: [], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null)]); + + var (tokens, _) = McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["verbose"] = JsonSerializer.SerializeToElement("true"), + }); + + tokens.Should().Equal("deploy", "--verbose=true"); + } + + [TestMethod] + [Description("PrepareExecution rejects positional route-segment values that look like a CLI option token: unlike an option, a positional segment has no separator that can escape the value, so it would be re-lexed as a fresh option once substituted into the token stream.")] + public void When_PositionalArgumentValueLooksLikeOptionToken_Then_Rejected() + { + var command = new ReplDocCommand( + Path: "contacts {id}", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [new ReplDocArgument("id", "string", Required: true, Description: null)], + Options: []); + + var action = () => McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["id"] = JsonSerializer.SerializeToElement("-t=denim"), + }); + + action.Should().Throw() + .WithMessage("*argument value*CLI option*positional*"); + } + + [TestMethod] + [Description("A signed numeric literal remains a valid positional route-segment value: it starts with a dash but IsSignedNumericLiteral carves it out, matching the CLI parser's own positional-vs-option rule.")] + public void When_PositionalArgumentValueIsSignedNumericLiteral_Then_Accepted() + { + var command = new ReplDocCommand( + Path: "contacts {id}", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [new ReplDocArgument("id", "string", Required: true, Description: null)], + Options: []); + + var (tokens, _) = McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["id"] = JsonSerializer.SerializeToElement("-42"), + }); + + tokens.Should().Equal("contacts", "-42"); + } + + [TestMethod] + [Description( + "End-to-end regression for the option-smuggling vulnerability: a tool call supplies a value " + + "for a visible bool option that itself looks like '-t=denim', an alias of a Hidden() option on " + + "the same route. Before the fix, ReconstructTokens emitted the value as its own token, which the " + + "bool flag declined to consume without a diagnostic (that decline is required so legitimate " + + "flag-chaining like '--verbose --other' keeps working) and which the parser then re-lexed as a " + + "fresh '-t' option, binding the hidden 'tenant' target. The inline '--verbose=-t=denim' token this " + + "test asserts on cannot be split apart that way, so 'tenant' must never appear as bound.")] + public void When_ToolCallValueLooksLikeHiddenOptionToken_Then_ItIsNotBoundAsAnOption() + { + var command = new ReplDocCommand( + Path: "deploy", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: [new ReplDocOption("verbose", "bool", Required: false, Description: null, Aliases: [], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null)]); + + var (tokens, _) = McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["verbose"] = JsonSerializer.SerializeToElement("-t=denim"), + }); + + tokens.Should().Equal("deploy", "--verbose=-t=denim"); + + var schema = new OptionSchema( + [ + new OptionSchemaEntry("--verbose", "verbose", OptionSchemaTokenKind.BoolFlag, ReplArity.ZeroOrOne), + new OptionSchemaEntry("-t", "tenant", OptionSchemaTokenKind.NamedOption, ReplArity.ZeroOrOne, IsHidden: true), + ], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["verbose"] = new OptionSchemaParameter("verbose", typeof(bool), ReplParameterMode.OptionOnly), + ["tenant"] = new OptionSchemaParameter("tenant", typeof(string), ReplParameterMode.OptionOnly, IsHidden: true), + }, + ReplCaseSensitivity.CaseSensitive); + + var parseResult = InvocationOptionParser.Parse( + [.. tokens.Skip(1)], + schema, + new ParsingOptions()); + + parseResult.NamedOptions.Should().NotContainKey("tenant"); + parseResult.NamedOptions.Should().ContainKey("verbose"); + parseResult.NamedOptions["verbose"].Should().ContainSingle().Which.Should().Be("-t=denim"); + } + private static ReplDocCommand CreateCaseDistinctOptionsCommand() => new( Path: "deploy", From bc662441bed9eb025ab5fdc0660de295aaa15a08 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 20:27:52 -0400 Subject: [PATCH 46/61] docs: correct the hidden-required-option failure timing claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OptionBuilder.Hidden()'s remark claimed hiding a required option "fails here rather than later" — true for options-group properties (ValidateOptionVisibility runs at configuration time), but not for direct handler parameters: those may still be satisfiable from DI or a synthesized progress channel, which is only knowable once a real service provider exists, so that case fails the first time discovery runs against one instead. Also document the InvalidOperationException WithOption can throw via that same configuration-time path. Investigated moving this to fail at Run() entry instead of lazily at first discovery, but the aggregate-throws-rather-than-omits behavior is deliberate and already covered by When_RequiredCommandOptionIsHiddenFluentlyWithoutAService_Then_AggregateDocumentationThrows and When_HiddenGlobalOwnsRequiredRouteOptionToken_Then_AggregateDocumentationFailsClosed — silently omitting would advertise an MCP tool that can never succeed. Left that design alone; this is a documentation-only fix. --- src/Repl.Core/CommandBuilder.cs | 4 ++++ src/Repl.Core/OptionBuilder.cs | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 2d523e09..b4e15a74 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -178,6 +178,10 @@ public CommandBuilder WithAlias(params string[] aliases) /// Receives the option metadata builder. /// The same command builder instance. /// No such option target is registered for this command. + /// + /// hid an options-group property that a required arity or + /// non-omittable CLR shape makes impossible to omit from an invocation. + /// public CommandBuilder WithOption(string targetName, Action configure) { ArgumentNullException.ThrowIfNull(configure); diff --git a/src/Repl.Core/OptionBuilder.cs b/src/Repl.Core/OptionBuilder.cs index 4942b927..3cb8953a 100644 --- a/src/Repl.Core/OptionBuilder.cs +++ b/src/Repl.Core/OptionBuilder.cs @@ -26,10 +26,13 @@ internal OptionBuilder( /// /// Parsing and binding are unaffected, so the option still binds when a command-line or REPL /// caller supplies it explicitly. An MCP tools/call that supplies it is rejected, because - /// the option is absent from the advertised tool schema. A hidden option must be optional; - /// hiding a required one fails here rather than later. This is a discovery filter, not an - /// access-control boundary: the token remains an invocable part of the command line for anyone - /// who knows it. + /// the option is absent from the advertised tool schema. A hidden option must be optional. For an + /// options-group property this fails immediately, here; a direct handler parameter may still be + /// satisfiable from DI or a synthesized progress channel, which is only knowable once a real + /// service provider exists, so that case instead fails the first time discovery runs against one + /// (an explicit documentation request or MCP's aggregate tools/list). This is a discovery + /// filter, not an access-control boundary: the token remains an invocable part of the + /// command line for anyone who knows it. /// /// Whether the option is hidden. /// The same builder instance. From 24882a33bc71e9566b61c8c7a21545f2079e4a28 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 20:43:23 -0400 Subject: [PATCH 47/61] fix: stop naming the hidden option once a client has a schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetSnapshotAsync rethrew HiddenRequiredOptionException verbatim on every fail-closed visibility retraction, including after a client had already received a working tool schema. That exception's message names the option's target, rendered token and route — the identity Hidden() was meant to withhold — so once any client has a working schema, the failure now surfaces as a generic McpException instead. The very first snapshot build (no client has ever had a schema) still lets the detailed exception through, matching When_HiddenRequiredOptionHasNoServiceFallback_Then_McpStartupFailsFast: that case is a cold-start configuration error for the operator, not a runtime retraction reaching a connected client. Strengthened the existing post-first-success regression test to also assert the exception message excludes the option's identity — it only checked the exception type before. --- src/Repl.Mcp/McpServerHandler.cs | 21 ++++++++++++++++++-- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 9 ++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 131b0070..ee97c0dc 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -333,8 +333,7 @@ private async ValueTask GetSnapshotAsync( } catch (HiddenRequiredOptionException) { - // A visibility retraction must fail closed: returning the previous snapshot would keep - // advertising an option the app explicitly hid from agents. + ThrowSanitizedIfAClientAlreadyHasASchema(previousSnapshot); throw; } catch (Exception) when ( @@ -354,6 +353,24 @@ previousSnapshot is not null } } + // No snapshot has ever been served: a cold-start configuration error, not a runtime retraction + // reaching an already-connected client. Let the caller's rethrow carry the detailed exception so + // the operator sees exactly which option and route are misconfigured. + // + // Once a client HAS a working schema, the same failure must fail closed (returning the previous + // snapshot would keep advertising an option the app explicitly hid), but the exception's own + // message names that option's target, rendered token and route — precisely the identity hiding it + // was meant to withhold — so it must not reach the client verbatim. A generic McpException (the + // pattern this handler already uses for other client-facing failures) reports the failure without + // disclosing what triggered it. + private static void ThrowSanitizedIfAClientAlreadyHasASchema(McpGeneratedSnapshot? previousSnapshot) + { + if (previousSnapshot is not null) + { + throw new McpException("Tool discovery is temporarily unavailable due to a server configuration error."); + } + } + private async ValueTask BuildCurrentSnapshotAsync( long snapshotVersion, CancellationToken cancellationToken) diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 537ce486..2a124592 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -908,7 +908,7 @@ public async Task When_OptionIsHiddenAfterFirstToolsList_Then_SecondToolsListOmi } [TestMethod] - [Description("If a post-start visibility change makes the new MCP contract impossible, refresh must fail closed rather than restore and permanently cache the old snapshot that still advertises the retracted option. Re-exposing it must then recover normally.")] + [Description("If a post-start visibility change makes the new MCP contract impossible, refresh must fail closed rather than restore and permanently cache the old snapshot that still advertises the retracted option. Re-exposing it must then recover normally. Because a client has already received a working schema by this point, the error must not name the option, its rendered token, or the route — that identity is exactly what Hidden() was meant to withhold.")] public async Task When_RequiredOptionIsHiddenAfterFirstToolsList_Then_RefreshFailsClosedUntilConfigurationRecovers() { CommandBuilder? deploy = null; @@ -923,8 +923,11 @@ public async Task When_RequiredOptionIsHiddenAfterFirstToolsList_Then_RefreshFai var secondRefresh = async () => await fixture.Client.ListToolsAsync().ConfigureAwait(false); advertisedBefore.Should().Contain("token"); - await firstRefresh.Should().ThrowAsync().ConfigureAwait(false); - await secondRefresh.Should().ThrowAsync().ConfigureAwait(false); + var firstFault = await firstRefresh.Should().ThrowAsync().ConfigureAwait(false); + var secondFault = await secondRefresh.Should().ThrowAsync().ConfigureAwait(false); + + firstFault.Which.Message.Should().NotContainAny("token", "--token", "deploy"); + secondFault.Which.Message.Should().NotContainAny("token", "--token", "deploy"); deploy.WithOption("token", static option => option.Hidden(isHidden: false)); var advertisedAfterRecovery = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); From 9388d8d682403203e431cc1cf9bef41c99c9382b Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 20:58:47 -0400 Subject: [PATCH 48/61] Fix runtime alias discovery parity --- .../Autocomplete/AutocompleteEngine.cs | 2 +- src/Repl.Core/CoreReplApp.Execution.cs | 3 +- src/Repl.Core/CoreReplApp.cs | 7 +- .../Documentation/DocumentationEngine.cs | 29 +++-- src/Repl.Core/Documentation/ReplDocOption.cs | 3 + .../Help/HelpTextBuilder.Rendering.cs | 3 +- .../Internal/Options/OptionSchema.cs | 91 ++++++++++++++-- .../Internal/Options/OptionSchemaBuilder.cs | 25 +++-- .../Parsing/InvocationOptionParser.cs | 8 +- src/Repl.Core/ParsingOptions.cs | 2 +- .../ShellCompletion/ShellCompletionEngine.cs | 6 +- .../Given_HelpDiscovery.cs | 101 +++++++++++++++++- src/Repl.Mcp/McpAutomationProjection.cs | 15 +-- src/Repl.Mcp/McpServerHandler.cs | 19 +++- src/Repl.McpTests/Given_McpDebounce.cs | 37 +++++++ src/Repl.McpTests/Given_McpServerEndToEnd.cs | 27 +++++ ...nteractiveAutocomplete_OptionCandidates.cs | 52 ++++++++- 17 files changed, 367 insertions(+), 63 deletions(-) diff --git a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs index 9e0ead51..47f410b1 100644 --- a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs +++ b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs @@ -1593,7 +1593,7 @@ internal static bool IsControlFreeValue(string value) => pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity); foreach (var entry in entries) { - if (entry.IsHidden || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) + if (!match.Route.OptionSchema.IsEntryDiscoverable(entry)) { continue; } diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index c53ab934..496b2c10 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -479,7 +479,8 @@ private async ValueTask TryHandleContextDeeplinkAsync( var parsedOptions = InvocationOptionParser.Parse( match.RemainingTokens, match.Route.OptionSchema, - commandParsingOptions); + commandParsingOptions, + GlobalOptionParser.BuildCustomTokenOwnership(_options.Parsing)); if (parsedOptions.HasErrors) { var firstError = parsedOptions.Diagnostics diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 786d24a1..dbb3c0b3 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -187,7 +187,12 @@ public CommandBuilder Map(string route, Delegate handler) .Select(existingRoute => existingRoute.Template)); _commands.Add(command); - var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing, _implicitServiceParameters); + var optionSchema = OptionSchemaBuilder.Build( + template, + command, + _options.Parsing, + _implicitServiceParameters, + () => _options.Parsing.OptionCaseSensitivity); command.AttachOptionSchema(optionSchema); command.ValidateOptionVisibility(); var routeDefinition = new RouteDefinition(template, command, moduleId); diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 6bb516e4..f7eb46be 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -258,7 +258,7 @@ parameter.Name is { } name && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true) && (includeHiddenOptions || !schema.IsOptionHidden(name)) && ShouldIncludeDocumentationOption( - route, name, serviceAvailability, customGlobalOwnership)) + route, name, includeHiddenOptions, serviceAvailability, customGlobalOwnership)) .Select(parameter => BuildDocumentationOption( schema, parameter, serviceAvailability, customGlobalOwnership)); var groupOptions = handlerParams @@ -271,7 +271,7 @@ parameter.Name is { } name prop.CanWrite && (includeHiddenOptions || !schema.IsOptionHidden(prop.Name)) && ShouldIncludeDocumentationOption( - route, prop.Name, serviceAvailability, customGlobalOwnership)) + route, prop.Name, includeHiddenOptions, serviceAvailability, customGlobalOwnership)) .Select(prop => BuildDocumentationOptionFromProperty( schema, prop, defaultInstance, customGlobalOwnership)); }); @@ -392,17 +392,22 @@ private bool IsServiceAvailable(Type serviceType, Dictionary service private bool ShouldIncludeDocumentationOption( RouteDefinition route, string parameterName, + bool includeHiddenOptions, Dictionary serviceAvailability, IReadOnlyDictionary customGlobalOwnership) { var schema = route.OptionSchema; + if (includeHiddenOptions && schema.IsOptionHidden(parameterName)) + { + return true; + } + var displayToken = schema.ResolveDisplayToken(parameterName); - var hasReachableNamedToken = schema.Entries.Any(entry => - string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) - && entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag - && !entry.IsHidden + var hasReachableToken = schema.Entries.Any(entry => + schema.IsAliasDiscoverable(entry) + && string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) && !customGlobalOwnership.ContainsKey(entry.Token)); - if (displayToken is null || hasReachableNamedToken) + if (displayToken is null || hasReachableToken) { return true; } @@ -514,8 +519,8 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( var displayToken = schema.ResolveDisplayToken(property.Name); var entries = schema.Entries .Where(entry => - string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase) - && !entry.IsHidden + schema.IsAliasDiscoverable(entry) + && string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase) && !customGlobalOwnership.ContainsKey(entry.Token)) .ToArray(); var aliases = entries @@ -555,6 +560,7 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( { IsHidden = schema.IsOptionHidden(property.Name), IsAutomationHidden = schema.IsOptionAutomationHidden(property.Name), + IsNamedTokenReachable = aliases.Length > 0, }; } @@ -567,8 +573,8 @@ private ReplDocOption BuildDocumentationOption( var displayToken = schema.ResolveDisplayToken(parameter.Name!); var entries = schema.Entries .Where(entry => - string.Equals(entry.ParameterName, parameter.Name, StringComparison.OrdinalIgnoreCase) - && !entry.IsHidden + schema.IsAliasDiscoverable(entry) + && string.Equals(entry.ParameterName, parameter.Name, StringComparison.OrdinalIgnoreCase) && !customGlobalOwnership.ContainsKey(entry.Token)) .ToArray(); var aliases = entries @@ -607,6 +613,7 @@ private ReplDocOption BuildDocumentationOption( { IsHidden = schema.IsOptionHidden(parameter.Name!), IsAutomationHidden = schema.IsOptionAutomationHidden(parameter.Name!), + IsNamedTokenReachable = aliases.Length > 0, }; } diff --git a/src/Repl.Core/Documentation/ReplDocOption.cs b/src/Repl.Core/Documentation/ReplDocOption.cs index 76e20af1..286b4354 100644 --- a/src/Repl.Core/Documentation/ReplDocOption.cs +++ b/src/Repl.Core/Documentation/ReplDocOption.cs @@ -38,4 +38,7 @@ public sealed record ReplDocOption( /// Consumers that generate agent-facing schemas must omit it. Not an access-control boundary. /// public bool IsAutomationHidden { get; init; } + + /// Whether automation can reconstruct this value through an ordinary named option token. + internal bool IsNamedTokenReachable { get; init; } = true; } diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 903ac940..3f5cf9cc 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -184,10 +184,9 @@ private static string BuildOptionSection( IReadOnlyDictionary customGlobalOwnership, Dictionary? groupProperties = null) { - var entries = schema.Entries + var entries = schema.DiscoverableEntries .Where(entry => string.Equals(entry.ParameterName, schemaParameter.Name, StringComparison.OrdinalIgnoreCase) - && !entry.IsHidden && !customGlobalOwnership.ContainsKey(entry.Token) && entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 0f827cff..2a88e7d4 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -12,13 +12,21 @@ public OptionSchema( IReadOnlyList entries, IReadOnlyDictionary parameters, ReplCaseSensitivity globalCaseSensitivity) + : this(entries, parameters, () => globalCaseSensitivity) + { + } + + public OptionSchema( + IReadOnlyList entries, + IReadOnlyDictionary parameters, + Func resolveGlobalCaseSensitivity) { Entries = entries; Parameters = parameters; - GlobalCaseSensitivity = globalCaseSensitivity; + _resolveGlobalCaseSensitivity = resolveGlobalCaseSensitivity; } - private ReplCaseSensitivity GlobalCaseSensitivity { get; } + private readonly Func _resolveGlobalCaseSensitivity; public IReadOnlyList Entries { get; } @@ -40,7 +48,8 @@ public OptionSchema( // the same result. A visibility change does not mutate a schema — it publishes a new // one (see WithParameter) — so these caches can never go stale. private OptionSchemaParameter[]? _discoverableParameters; - private OptionSchemaEntry[]? _discoverableEntries; + private OptionSchemaEntry[]? _discoverableSensitiveEntries; + private OptionSchemaEntry[]? _discoverableInsensitiveEntries; /// /// Parameters that discovery surfaces may advertise: option-bearing and not hidden. @@ -59,10 +68,19 @@ .. Parameters.Values.Where(parameter => /// value aliases, negated flags), so filtering here keeps every token of a hidden option /// out of completion, not just its canonical form. /// - public IReadOnlyList DiscoverableEntries => - _discoverableEntries ??= [.. Entries.Where(entry => !entry.IsHidden && !IsOptionHidden(entry.ParameterName))]; + public IReadOnlyList DiscoverableEntries + { + get + { + var globalCaseSensitivity = _resolveGlobalCaseSensitivity(); + return globalCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? _discoverableInsensitiveEntries ??= BuildDiscoverableEntries(globalCaseSensitivity) + : _discoverableSensitiveEntries ??= BuildDiscoverableEntries(globalCaseSensitivity); + } + } - private string[]? _discoverableTokens; + private string[]? _discoverableSensitiveTokens; + private string[]? _discoverableInsensitiveTokens; /// /// minus the tokens of hidden options. @@ -72,8 +90,16 @@ .. Parameters.Values.Where(parameter => /// '--secret'?" turns a validation error into a way to enumerate hidden options by probing at /// small edit distance. Parsing keeps the full set, so a hidden option still binds when supplied. /// - public IReadOnlyCollection DiscoverableTokens => - _discoverableTokens ??= [.. DiscoverableEntries.Select(entry => entry.Token).Distinct(StringComparer.Ordinal)]; + public IReadOnlyCollection DiscoverableTokens + { + get + { + var globalCaseSensitivity = _resolveGlobalCaseSensitivity(); + return globalCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? _discoverableInsensitiveTokens ??= BuildDiscoverableTokens(globalCaseSensitivity) + : _discoverableSensitiveTokens ??= BuildDiscoverableTokens(globalCaseSensitivity); + } + } /// /// Whether the named parameter is hidden from discovery. Unknown names are not hidden: @@ -105,7 +131,7 @@ public OptionSchema WithParameter(OptionSchemaParameter parameter) [parameter.Name] = parameter, }; - return new OptionSchema(Entries, parameters, GlobalCaseSensitivity); + return new OptionSchema(Entries, parameters, _resolveGlobalCaseSensitivity); } public OptionSchema WithAliasVisibility( @@ -136,7 +162,7 @@ public OptionSchema WithAliasVisibility( return entry with { IsHidden = isHidden }; }).ToArray(); return found - ? new OptionSchema(entries, Parameters, GlobalCaseSensitivity) + ? new OptionSchema(entries, Parameters, _resolveGlobalCaseSensitivity) : throw new KeyNotFoundException( $"No alias token '{alias}' is registered for option target '{parameterName}'."); } @@ -148,13 +174,56 @@ private bool TokensAreEquivalent( { var effectiveCaseSensitivity = entry.CaseSensitivity ?? currentGlobalCaseSensitivity - ?? GlobalCaseSensitivity; + ?? _resolveGlobalCaseSensitivity(); var comparison = effectiveCaseSensitivity == ReplCaseSensitivity.CaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; return string.Equals(entry.Token, token, comparison); } + private OptionSchemaEntry[] BuildDiscoverableEntries(ReplCaseSensitivity globalCaseSensitivity) => + [ + .. Entries.Where(entry => IsEntryDiscoverable(entry, globalCaseSensitivity)), + ]; + + private string[] BuildDiscoverableTokens(ReplCaseSensitivity globalCaseSensitivity) => + [ + .. BuildDiscoverableEntries(globalCaseSensitivity) + .Select(entry => entry.Token) + .Distinct(StringComparer.Ordinal), + ]; + + internal bool IsEntryDiscoverable(OptionSchemaEntry entry) => + !IsOptionHidden(entry.ParameterName) && IsAliasDiscoverable(entry); + + internal bool IsAliasDiscoverable(OptionSchemaEntry entry) => + IsAliasDiscoverable(entry, _resolveGlobalCaseSensitivity()); + + private bool IsEntryDiscoverable(OptionSchemaEntry entry, ReplCaseSensitivity globalCaseSensitivity) => + !IsOptionHidden(entry.ParameterName) && IsAliasDiscoverable(entry, globalCaseSensitivity); + + private bool IsAliasDiscoverable(OptionSchemaEntry entry, ReplCaseSensitivity globalCaseSensitivity) + { + if (entry.IsHidden) + { + return false; + } + + // HiddenAliases controls secondary aliases only; a later global-mode change must not + // turn a secondary alias into a way to withdraw the canonical token. + if (ReferenceEquals(entry, FindNamedEntry(entry.ParameterName))) + { + return true; + } + + return !Entries.Any(hidden => + hidden.IsHidden + && string.Equals(hidden.ParameterName, entry.ParameterName, StringComparison.OrdinalIgnoreCase) + && hidden.TokenKind == entry.TokenKind + && string.Equals(hidden.InjectedValue, entry.InjectedValue, StringComparison.Ordinal) + && TokensAreEquivalent(hidden, entry.Token, globalCaseSensitivity)); + } + public IReadOnlyList ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity) { if (string.IsNullOrWhiteSpace(token)) diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 4347b130..cb2f600e 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -10,7 +10,8 @@ public static OptionSchema Build( RouteTemplate template, CommandBuilder command, ParsingOptions parsingOptions, - ImplicitServiceParameterRegistry implicitServiceParameters) + ImplicitServiceParameterRegistry implicitServiceParameters, + Func? resolveGlobalCaseSensitivity = null) { ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(command); @@ -55,7 +56,10 @@ public static OptionSchema Build( ValidatePositionalBindingCompatibility(regularPositionalParameterNames, groupPositionalPropertyNames); ValidateTokenCollisions(entries, parsingOptions, template); - return new OptionSchema(entries, parameters, parsingOptions.OptionCaseSensitivity); + return new OptionSchema( + entries, + parameters, + resolveGlobalCaseSensitivity ?? (() => parsingOptions.OptionCaseSensitivity)); } private static bool ShouldSkipSchemaParameter( @@ -161,13 +165,6 @@ private static string ResolveCanonicalToken(string parameterName, ReplOptionAttr return EnsureLongPrefix(canonicalName); } - private static StringComparer ResolveEffectiveOptionComparer( - ReplOptionAttribute? optionAttribute, - ParsingOptions parsingOptions) => - (optionAttribute?.CaseSensitivityOverride ?? parsingOptions.OptionCaseSensitivity) == ReplCaseSensitivity.CaseInsensitive - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - private static void AppendOptionAliases( ParameterInfo parameter, OptionSchemaTokenKind tokenKind, @@ -176,10 +173,11 @@ private static void AppendOptionAliases( ParsingOptions parsingOptions, List entries) { - var comparer = ResolveEffectiveOptionComparer(optionAttribute, parsingOptions); foreach (var alias in optionAttribute?.Aliases ?? []) { - if (optionAttribute?.HiddenAliases.Contains(alias, comparer) == true) + // Preserve case-distinct aliases in the parse schema so inherited visibility can be + // reevaluated if the global case mode changes after mapping. + if (optionAttribute?.HiddenAliases.Contains(alias, StringComparer.Ordinal) == true) { continue; } @@ -522,10 +520,11 @@ private static void AppendPropertyOptionAliases( ParsingOptions parsingOptions, List entries) { - var comparer = ResolveEffectiveOptionComparer(optionAttribute, parsingOptions); foreach (var alias in optionAttribute?.Aliases ?? []) { - if (optionAttribute?.HiddenAliases.Contains(alias, comparer) == true) + // Preserve case-distinct aliases in the parse schema so inherited visibility can be + // reevaluated if the global case mode changes after mapping. + if (optionAttribute?.HiddenAliases.Contains(alias, StringComparer.Ordinal) == true) { continue; } diff --git a/src/Repl.Core/Parsing/InvocationOptionParser.cs b/src/Repl.Core/Parsing/InvocationOptionParser.cs index 78a6e76e..798a47f5 100644 --- a/src/Repl.Core/Parsing/InvocationOptionParser.cs +++ b/src/Repl.Core/Parsing/InvocationOptionParser.cs @@ -127,7 +127,8 @@ public static OptionParsingResult Parse( public static OptionParsingResult Parse( IReadOnlyList tokens, OptionSchema schema, - ParsingOptions options) + ParsingOptions options, + IReadOnlyDictionary? customGlobalOwnership = null) { ArgumentNullException.ThrowIfNull(tokens); ArgumentNullException.ThrowIfNull(schema); @@ -142,6 +143,7 @@ public static OptionParsingResult Parse( : tokens; var namedOptions = new Dictionary>(tokenComparer); var positionalArguments = new List(tokens.Count); + customGlobalOwnership ??= new Dictionary(StringComparer.Ordinal); var parseAsPositional = false; for (var index = 0; index < effectiveTokens.Count; index++) @@ -187,6 +189,7 @@ public static OptionParsingResult Parse( inlineValue, schema, options, + customGlobalOwnership, namedOptions, diagnostics); continue; @@ -270,6 +273,7 @@ private static void HandleUnknownOption( string? inlineValue, OptionSchema schema, ParsingOptions options, + IReadOnlyDictionary customGlobalOwnership, Dictionary> namedOptions, List diagnostics) { @@ -279,7 +283,7 @@ private static void HandleUnknownOption( // would let a caller enumerate hidden options by probing at small edit distance. var suggestion = TryResolveSuggestion( optionToken, - schema.DiscoverableTokens, + [.. schema.DiscoverableTokens.Where(token => !customGlobalOwnership.ContainsKey(token))], options.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive); var message = suggestion is null ? $"Unknown option '{optionToken}'." diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index d08f6121..de63a4bd 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -233,7 +233,7 @@ internal void SetGlobalOptionAliasHidden(string canonicalName, string alias, boo } else { - hiddenAliases.Remove(registeredAlias); + hiddenAliases.RemoveWhere(candidate => comparer.Equals(candidate, registeredAlias)); } _globalOptions[canonicalName] = definition with { HiddenAliases = [.. hiddenAliases] }; diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs index 1e35d866..975e17da 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -424,8 +424,7 @@ private bool TryResolveShellScopedProvider( return !PrefixHasQuoteContext(currentTokenPrefix) && TryResolvePendingRouteOption(match, out entry) - && !entry.IsHidden - && !match.Route.OptionSchema.IsOptionHidden(entry.ParameterName) + && match.Route.OptionSchema.IsEntryDiscoverable(entry) && match.Route.Command.Completions.TryGetValue(entry.ParameterName, out completion!) && match.Route.Command.IsCompletionShellScoped(entry.ParameterName); } @@ -605,8 +604,7 @@ private bool TryAddRouteEnumValueCandidates( List candidates) { if (!TryResolvePendingRouteOption(match, out var entry) - || entry.IsHidden - || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) + || !match.Route.OptionSchema.IsEntryDiscoverable(entry)) { return false; } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index fb7bd20d..fe801c6b 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -157,14 +157,14 @@ static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"] } [TestMethod] - [Description("Unhiding through an opposite-case alias under the effective case-insensitive comparer restores every parser-equivalent spelling to help and documentation while both remain parsable.")] - public void When_FluentAliasIsUnhiddenThroughEquivalentCasing_Then_AllEquivalentSpellingsReturnToDiscovery() + [Description("After mapping under the sensitive default, switching to case-insensitive mode and unhiding through opposite casing restores one deduplicated discovery representative while both spellings remain parsable.")] + public void When_GlobalCaseModeChangesAfterMappingAndAliasIsUnhidden_Then_OneEquivalentSpellingReturnsToDiscovery() { var sut = ReplApp.Create(); - sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); var command = sut.Map( "publish", static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"])] string? tenant = null) => tenant ?? "none"); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); command.WithOption("tenant", static option => { option.HiddenAlias("--account"); @@ -225,6 +225,35 @@ public void When_GlobalAliasIsHidden_Then_HelpOmitsItButParsingRetainsIt() legacy.Text.Should().Contain("acme"); } + [TestMethod] + [Description("Global hide followed by opposite-case unhide uses the current case-insensitive comparer, restoring one deduplicated help representative while both spellings remain parsable.")] + public void When_GlobalAliasIsUnhiddenThroughEquivalentCasing_Then_OneRepresentativeReturnsToHelp() + { + var sut = ReplApp.Create(); + sut.Options(options => + options.Parsing.AddGlobalOption("organization", aliases: ["--legacy-org", "--LEGACY-ORG"])); + sut.Options(options => + { + options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive; + options.Parsing.GlobalOption("organization").HiddenAlias("--legacy-org"); + options.Parsing.GlobalOption("organization").HiddenAlias("--LEGACY-ORG", isHidden: false); + }); + sut.Map("show", static string (IGlobalOptionsAccessor globals) => + globals.GetValue("organization") ?? "none"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + var lower = ConsoleCaptureHelper.Capture(() => + sut.Run(["--legacy-org", "south", "show", "--no-logo"])); + var upper = ConsoleCaptureHelper.Capture(() => + sut.Run(["--LEGACY-ORG", "north", "show", "--no-logo"])); + + help.Text.Should().Contain("--legacy-org"); + lower.ExitCode.Should().Be(0, lower.Text); + lower.Text.Should().Contain("south"); + upper.ExitCode.Should().Be(0, upper.Text); + upper.Text.Should().Contain("north"); + } + [TestMethod] [Description("A hidden command option stays bindable when explicitly provided but is omitted from command help.")] public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds() @@ -558,6 +587,72 @@ public void When_MistypingAHiddenOption_Then_TheSuggestionDoesNotRevealIt() visible.Text.Should().Contain("--environment", "a visible option is still suggested, so the filter is not simply disabling suggestions"); } + [TestMethod] + [Description("A hidden global that owns a visible route token must suppress that unreachable token from typo suggestions just as it is suppressed from help, completion, and documentation; an unrelated route option remains a positive-control suggestion.")] + public void When_HiddenGlobalOwnsVisibleRouteToken_Then_TypoSuggestionDoesNotRevealIt() + { + var sut = CoreReplApp.Create(); + sut.Options(options => + { + options.Parsing.AddGlobalOption("secret"); + options.Parsing.GlobalOption("secret").Hidden(); + }); + sut.Map( + "deploy", + static string ([ReplOption] string? secret = null, [ReplOption] string? region = null) => $"{secret}:{region}"); + + var hidden = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--secre", "x", "--no-logo"])); + var visible = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--regoin", "x", "--no-logo"])); + + hidden.Text.Should().NotContain("Did you mean '--secret'"); + visible.Text.Should().Contain("Did you mean '--region'", "route suggestions must remain enabled"); + } + + [TestMethod] + [Description("An inherited declarative hidden alias is reevaluated when the global case mode changes after mapping: once the parser considers the visible and hidden spellings equivalent, discovery hides both aliases while the canonical token and parsing remain available.")] + public void When_GlobalCaseModeChangesAfterMapping_Then_DeclarativeHiddenAliasPrecedenceIsReevaluated() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + static string ([ReplOption(Name = "tenant", Aliases = ["--ACCOUNT"], HiddenAliases = ["--account"])] string? tenant = null) => tenant ?? "none"); + sut.Options(options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--ACCOUNT", "acme", "--no-logo"])); + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + help.Text.Should().Contain("--tenant").And.NotContain("--account").And.NotContain("--ACCOUNT"); + option.Aliases.Should().ContainSingle().Which.Should().Be("--tenant"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("acme"); + } + + [TestMethod] + [Description("Human documentation retains an option when a global shadows its ordinary flag but an unowned reverse alias remains invocable; help and execution expose the same reachable reverse token.")] + public void When_GlobalOwnsOrdinaryFlagButReverseAliasRemains_Then_HumanDocumentationRetainsReverseAlias() + { + var sut = CoreReplApp.Create(); + sut.Options(options => + { + options.Parsing.AddGlobalOption("force"); + options.Parsing.GlobalOption("force").Hidden(); + }); + sut.Map( + "deploy", + static string ([ReplOption(ReverseAliases = ["--no-force"])] bool force = true) => force.ToString()); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--help", "--no-logo"])); + var invocation = ConsoleCaptureHelper.Capture(() => sut.Run(["deploy", "--no-force", "--no-logo"])); + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + help.Text.Should().Contain("--no-force").And.NotContain("--force"); + option.Aliases.Should().BeEmpty(); + option.ReverseAliases.Should().ContainSingle().Which.Should().Be("--no-force"); + invocation.ExitCode.Should().Be(0, invocation.Text); + invocation.Text.Should().Contain("False"); + } + [TestMethod] [Description("A nullable reference option with no default has its arity inferred as ExactlyOne, but arity governs how many values the token consumes when present — omitting it binds null perfectly well. Hiding it must therefore be allowed; only an explicitly declared required arity should override what the CLR shape says.")] public void When_HidingAnOmittableOptionWithInferredArity_Then_ItIsAllowed() diff --git a/src/Repl.Mcp/McpAutomationProjection.cs b/src/Repl.Mcp/McpAutomationProjection.cs index bba144b6..c7ed19c1 100644 --- a/src/Repl.Mcp/McpAutomationProjection.cs +++ b/src/Repl.Mcp/McpAutomationProjection.cs @@ -21,15 +21,15 @@ internal static class McpAutomationProjection { public static ReplDocumentationModel Apply(ReplDocumentationModel model) { - if (!model.Commands.Any(HasAutomationHiddenOption)) + if (!model.Commands.Any(HasUnavailableOption)) { return model; } var projected = model.Commands - .Where(static command => !command.Options.Any(static option => option.IsAutomationHidden && option.Required)) - .Select(static command => HasAutomationHiddenOption(command) - ? command with { Options = [.. command.Options.Where(static option => !option.IsAutomationHidden)] } + .Where(static command => !command.Options.Any(static option => IsUnavailable(option) && option.Required)) + .Select(static command => HasUnavailableOption(command) + ? command with { Options = [.. command.Options.Where(static option => !IsUnavailable(option))] } : command) .ToArray(); @@ -49,6 +49,9 @@ public static ReplDocumentationModel Apply(ReplDocumentationModel model) // supply it and omitting it fails to bind, so every call would fail. Withdrawing the tool is // preferable to advertising an impossible one — and unlike the all-surfaces Hidden axis this is // not rejected at configuration time, because the human command line remains perfectly usable. - private static bool HasAutomationHiddenOption(ReplDocCommand command) => - command.Options.Any(static option => option.IsAutomationHidden); + private static bool HasUnavailableOption(ReplDocCommand command) => + command.Options.Any(static option => IsUnavailable(option)); + + private static bool IsUnavailable(ReplDocOption option) => + option.IsAutomationHidden || !option.IsNamedTokenReachable; } diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 131b0070..481c5166 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -470,7 +470,7 @@ private void AttachServer(McpServer? server) } } - private sealed record SnapshotVersionState( + internal sealed record SnapshotVersionState( long Version, long LastVisibilityRetractionVersion); @@ -519,24 +519,35 @@ private void EnsureRootsNotificationHandler(McpServer server) }); } - private void OnRoutingInvalidated(bool isVisibilityRetraction) + internal static SnapshotVersionState PublishSnapshotInvalidation( + ref SnapshotVersionState snapshotState, + bool isVisibilityRetraction, + Action? beforePublish = null) { SnapshotVersionState currentState; SnapshotVersionState invalidatedState; do { - currentState = Volatile.Read(ref _snapshotState); + currentState = Volatile.Read(ref snapshotState); var invalidatedVersion = currentState.Version + 1; invalidatedState = new SnapshotVersionState( invalidatedVersion, isVisibilityRetraction ? invalidatedVersion : currentState.LastVisibilityRetractionVersion); + beforePublish?.Invoke(invalidatedState); } while (!ReferenceEquals( - Interlocked.CompareExchange(ref _snapshotState, invalidatedState, currentState), + Interlocked.CompareExchange(ref snapshotState, invalidatedState, currentState), currentState)); + return invalidatedState; + } + + private void OnRoutingInvalidated(bool isVisibilityRetraction) + { + PublishSnapshotInvalidation(ref _snapshotState, isVisibilityRetraction); + if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) { Interlocked.Exchange(ref _compatibilityIntroServed, 0); diff --git a/src/Repl.McpTests/Given_McpDebounce.cs b/src/Repl.McpTests/Given_McpDebounce.cs index 79b7c0a2..72d79567 100644 --- a/src/Repl.McpTests/Given_McpDebounce.cs +++ b/src/Repl.McpTests/Given_McpDebounce.cs @@ -72,6 +72,38 @@ public void When_RebuildThrows_Then_ServerContinuesWithStaleRoutes() recoveredTools.Should().Contain(tool => string.Equals(tool.Name, "added-after", StringComparison.Ordinal)); } + [TestMethod] + [Description("Pausing immediately before invalidation publication leaves readers on the complete old version/watermark pair; releasing publication exposes the complete new pair atomically.")] + public void When_VisibilityRetractionPublicationIsPaused_Then_ReaderObservesOnlyCompleteStates() + { + var initial = new McpServerHandler.SnapshotVersionState( + Version: 7, + LastVisibilityRetractionVersion: 3); + var holder = new SnapshotStateHolder(initial); + using var publicationReady = new ManualResetEventSlim(initialState: false); + using var releasePublication = new ManualResetEventSlim(initialState: false); + + var publication = Task.Run(() => McpServerHandler.PublishSnapshotInvalidation( + ref holder.State, + isVisibilityRetraction: true, + beforePublish: candidate => + { + candidate.Version.Should().Be(8); + candidate.LastVisibilityRetractionVersion.Should().Be(8); + publicationReady.Set(); + releasePublication.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue(); + })); + + publicationReady.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue(); + Volatile.Read(ref holder.State).Should().BeSameAs(initial); + + releasePublication.Set(); + SyncWait(publication); + var published = Volatile.Read(ref holder.State); + published.Version.Should().Be(8); + published.LastVisibilityRetractionVersion.Should().Be(8); + } + [TestMethod] [Description("If an option is hidden while a successful MCP snapshot projection is in flight, that request retries against the newer visibility version instead of returning the stale schema it already constructed.")] public void When_VisibilityRetractionOccursDuringSuccessfulBuild_Then_InFlightRequestRetries() @@ -207,4 +239,9 @@ private sealed class NullIoContext : IReplIoContext public bool IsHostedSession => false; public string? SessionId => null; } + private sealed class SnapshotStateHolder(McpServerHandler.SnapshotVersionState state) + { + public McpServerHandler.SnapshotVersionState State = state; + } + } diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 537ce486..eef143a6 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -564,6 +564,33 @@ public async Task When_HiddenCommandOptionIsSuppliedToToolCall_Then_CallIsReject text.Should().Contain("error occurred invoking"); } + [TestMethod] + [Description("Human documentation may retain reverse/value-only reachability, but MCP cannot reconstruct an arbitrary semantic value without an ordinary named token; the optional field is therefore omitted from both schema and allow-list while the tool remains callable.")] + public async Task When_OnlyReverseAliasRemainsReachable_Then_McpOmitsTheSemanticOption() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Options(options => + { + options.Parsing.AddGlobalOption("force"); + options.Parsing.GlobalOption("force").Hidden(); + }); + app.Map( + "deploy", + static string ([ReplOption(ReverseAliases = ["--no-force"])] bool force = true) => force.ToString()); + }); + + var advertised = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + var result = await fixture.Client.CallToolAsync( + toolName: "deploy", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + advertised.Should().NotContain("force"); + result.IsError.Should().NotBeTrue(); + string.Join('\n', result.Content.OfType().Select(static block => block.Text)) + .Should().Contain("True"); + } + [TestMethod] [Description("The MCP half of the AutomationHidden pair: the advertised tool schema omits the option, and because the schema and the accepted argument list are built from the same option list, tools/call rejects it too. The help half is asserted separately, where the same option stays listed and binds.")] public async Task When_CommandOptionIsAutomationHidden_Then_McpOmitsItAndRejectsIt() diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs index 6c181892..dd8d4971 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_OptionCandidates.cs @@ -144,14 +144,14 @@ public async Task When_FluentAliasHasCaseEquivalentInInsensitiveMode_Then_AllEqu } [TestMethod] - [Description("Unhiding through an opposite-case alias under a case-insensitive comparer restores all equivalent aliases and their values to both interactive and shell completion.")] - public async Task When_FluentAliasIsUnhiddenThroughEquivalentCasing_Then_AllEquivalentSpellingsReturnToCompletion() + [Description("After mapping under the sensitive default, switching to case-insensitive mode and unhiding through opposite casing restores one deduplicated completion representative and values for both spellings.")] + public async Task When_GlobalCaseModeChangesAfterMappingAndAliasIsUnhidden_Then_OneEquivalentSpellingReturnsToCompletion() { var sut = CoreReplApp.Create(); - sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); var command = sut.Map( "deploy", static string ([ReplOption(Name = "mode", Aliases = ["--legacy-mode", "--LEGACY-MODE"])] ProbeMode mode = ProbeMode.Debug) => mode.ToString()); + sut.Options(static options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); command.WithOption("mode", static option => { option.HiddenAlias("--legacy-mode"); @@ -171,6 +171,29 @@ public async Task When_FluentAliasIsUnhiddenThroughEquivalentCasing_Then_AllEqui upperValues.Should().Contain(nameof(ProbeMode.Debug)); } + [TestMethod] + [Description("Global hide followed by opposite-case unhide uses the current case-insensitive comparer, restoring one deduplicated representative to interactive and shell completion.")] + public async Task When_GlobalAliasIsUnhiddenThroughEquivalentCasing_Then_OneRepresentativeReturnsToCompletion() + { + var sut = CoreReplApp.Create(); + sut.Options(options => + options.Parsing.AddGlobalOption("organization", aliases: ["--legacy-org", "--LEGACY-ORG"])); + sut.Options(options => + { + options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive; + options.Parsing.GlobalOption("organization").HiddenAlias("--legacy-org"); + options.Parsing.GlobalOption("organization").HiddenAlias("--LEGACY-ORG", isHidden: false); + }); + sut.Map("deploy", static () => "ok"); + + var interactive = await ResolveAutocompleteAsync(sut, "--").ConfigureAwait(false); + var shell = await ResolveShellCandidatesAsync(new ShellCompletionEngine(sut), "app --").ConfigureAwait(false); + + interactive.Suggestions.Select(static suggestion => suggestion.Value) + .Should().Contain("--legacy-org"); + shell.Should().Contain("--legacy-org"); + } + [TestMethod] [Description("A case-sensitive option override wins over the case-insensitive global mode in fluent visibility and completion: only the exact hidden alias and its values disappear.")] public async Task When_FluentAliasUsesSensitiveOverrideUnderInsensitiveGlobal_Then_OnlyExactSpellingIsHiddenFromCompletion() @@ -195,6 +218,29 @@ static string ([ReplOption(Name = "mode", Aliases = ["--legacy-mode", "--LEGACY- visibleValues.Should().Contain(nameof(ProbeMode.Debug)); } + [TestMethod] + [Description("Changing the inherited global case mode after mapping reevaluates declarative hidden-alias precedence for interactive and shell name/value completion without hiding the canonical token.")] + public async Task When_GlobalCaseModeChangesAfterMapping_Then_DeclarativeHiddenAliasCompletionIsReevaluated() + { + var sut = CoreReplApp.Create(); + sut.Map( + "deploy", + static string ([ReplOption(Name = "mode", Aliases = ["--LEGACY-MODE"], HiddenAliases = ["--legacy-mode"])] ProbeMode mode = ProbeMode.Debug) => mode.ToString()); + sut.Options(options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + + var interactiveNames = await ResolveAutocompleteAsync(sut, "deploy --").ConfigureAwait(false); + var shell = new ShellCompletionEngine(sut); + var shellNames = await ResolveShellCandidatesAsync(shell, "app deploy --").ConfigureAwait(false); + var interactiveValues = await ResolveAutocompleteAsync(sut, "deploy --LEGACY-MODE ").ConfigureAwait(false); + var shellValues = await ResolveShellCandidatesAsync(shell, "app deploy --legacy-mode ").ConfigureAwait(false); + + interactiveNames.Suggestions.Select(static suggestion => suggestion.Value) + .Should().Contain("--mode").And.NotContain("--LEGACY-MODE").And.NotContain("--legacy-mode"); + shellNames.Should().Contain("--mode").And.NotContain("--LEGACY-MODE").And.NotContain("--legacy-mode"); + interactiveValues.Suggestions.Should().BeEmpty(); + shellValues.Should().BeEmpty(); + } + [TestMethod] [Description("A hidden option does not expose its enum values even after the caller types its token by hand — probing must not confirm the option exists. The visible sibling is asserted in the same pass as a positive control: two bare BeEmpty assertions would also pass if this shape offered no values at all, and would then stay green with the visibility filter deleted.")] public async Task When_HiddenEnumOptionAwaitsValue_Then_OnlyTheVisibleSiblingOffersValues() From 87b99140a4e004c1dcc26897ef86e9581b9275f0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:04:44 -0400 Subject: [PATCH 49/61] fix: don't let a broken service registration crash discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsServiceAvailable called GetService directly to probe whether a hidden-required option's CLR type has a DI fallback. A registration that throws on resolution — most commonly a scoped service resolved from the root provider under ValidateScopes, or any constructor that fails — propagated straight through documentation/MCP discovery, crashing it with that registration's own exception instead of the intended hidden-required diagnosis, for every other route in the app. Treat a throw the same as a null result: unavailable. Investigated using IServiceProviderIsService to avoid activation entirely, but Repl.Core is enforced dependency-free at runtime (see the csproj's _DisallowedPackageReference check) and that type lives in Microsoft.Extensions.DependencyInjection.Abstractions — out of reach here. The try/catch closes the crash; it does not avoid activating a transient factory, which is a smaller, accepted residual cost. --- .../Documentation/DocumentationEngine.cs | 14 +++++++++++++- .../Given_HelpDiscovery.cs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 6bb516e4..86c99419 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -384,7 +384,19 @@ private bool IsServiceAvailable(Type serviceType, Dictionary service return available; } - available = app.CurrentServiceProvider.GetService(serviceType) is not null; + // GetService can activate a transient factory or throw outright — a scoped registration + // resolved from a root provider under ValidateScopes is a common way this happens. A + // misbehaving registration must not crash discovery for every other route over one option's + // fallback check; treat a throw here the same as a null result, unavailable. + try + { + available = app.CurrentServiceProvider.GetService(serviceType) is not null; + } + catch (Exception) + { + available = false; + } + serviceAvailability[serviceType] = available; return available; } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index fb7bd20d..0ffd2c3c 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -334,6 +334,23 @@ public void When_RequiredCommandOptionIsHiddenFluentlyWithoutAService_Then_Aggre .WithMessage("Option target 'internalToken' (rendered as '--internal-token') for command 'deploy' cannot be hidden because it is required. Either drop the Required/Arity constraint on the option, or hide a different one."); } + [TestMethod] + [Description("A service registration that throws on resolution — a scoped registration resolved from the root provider, or any constructor that fails — must be treated as unavailable rather than letting that registration's own exception crash discovery. The caller must see the standard hidden-required diagnosis, not whatever the broken registration happened to throw.")] + public void When_ServiceFallbackRegistrationThrowsOnResolution_Then_TreatedAsUnavailable() + { + var sut = ReplApp.Create(services => + services.AddSingleton(_ => throw new InvalidOperationException("Simulated resolution failure"))); + var command = sut.Map( + "deploy", + ([ReplOption(Name = "internal-token", Arity = ReplArity.ExactlyOne)] string internalToken) => internalToken); + command.WithOption("internalToken", option => option.Hidden()); + + var document = () => sut.CreateDocumentationModel(); + + document.Should().Throw() + .WithMessage("Option target 'internalToken' (rendered as '--internal-token') for command 'deploy' cannot be hidden because it is required.*"); + } + [TestMethod] [Description("A registered service is an established omission fallback in HandlerArgumentBinder and runs before the explicit-arity failure. Both fluent and declarative hiding must therefore remain legal: callers can omit the options and DI still supplies the handler values.")] public void When_RequiredOptionTypeHasAServiceFallback_Then_HidingAndOmissionAreAllowed() From d5d07ba42e6c54058463a67f31efcec08e0e4954 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:12:39 -0400 Subject: [PATCH 50/61] refactor: fold three inline visibility checks into OptionSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellCompletionEngine (two sites) and AutocompleteEngine re-derived "entry.IsHidden || schema.IsOptionHidden(entry.ParameterName)" inline instead of reusing OptionSchema.DiscoverableEntries's own rule — the same duplication pattern that produced repeated fix commits across help, documentation and completion earlier in this branch. Added OptionSchema.IsEntryDiscoverable for callers holding one resolved entry, and rewrote DiscoverableEntries on top of it so there is one expression of the rule instead of four. Behavior-preserving: full Repl.Tests and Repl.IntegrationTests suites pass unchanged. Left DocumentationEngine's reachability check alone — it also folds in global-token ownership, a materially different (and correct) condition, not the same duplication. --- src/Repl.Core/Autocomplete/AutocompleteEngine.cs | 2 +- src/Repl.Core/Internal/Options/OptionSchema.cs | 11 ++++++++++- .../ShellCompletion/ShellCompletionEngine.cs | 6 ++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs index 9e0ead51..47f410b1 100644 --- a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs +++ b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs @@ -1593,7 +1593,7 @@ internal static bool IsControlFreeValue(string value) => pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity); foreach (var entry in entries) { - if (entry.IsHidden || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) + if (!match.Route.OptionSchema.IsEntryDiscoverable(entry)) { continue; } diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 0f827cff..c2fd2d2e 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -60,7 +60,16 @@ .. Parameters.Values.Where(parameter => /// out of completion, not just its canonical form. /// public IReadOnlyList DiscoverableEntries => - _discoverableEntries ??= [.. Entries.Where(entry => !entry.IsHidden && !IsOptionHidden(entry.ParameterName))]; + _discoverableEntries ??= [.. Entries.Where(IsEntryDiscoverable)]; + + /// + /// Whether a single entry may be advertised by discovery surfaces. Exposed for call sites that + /// already have one resolved in hand (e.g. from + /// ) and would otherwise re-derive this exact condition inline — + /// is this same rule applied to the whole collection. + /// + public bool IsEntryDiscoverable(OptionSchemaEntry entry) => + !entry.IsHidden && !IsOptionHidden(entry.ParameterName); private string[]? _discoverableTokens; diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs index 1e35d866..975e17da 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -424,8 +424,7 @@ private bool TryResolveShellScopedProvider( return !PrefixHasQuoteContext(currentTokenPrefix) && TryResolvePendingRouteOption(match, out entry) - && !entry.IsHidden - && !match.Route.OptionSchema.IsOptionHidden(entry.ParameterName) + && match.Route.OptionSchema.IsEntryDiscoverable(entry) && match.Route.Command.Completions.TryGetValue(entry.ParameterName, out completion!) && match.Route.Command.IsCompletionShellScoped(entry.ParameterName); } @@ -605,8 +604,7 @@ private bool TryAddRouteEnumValueCandidates( List candidates) { if (!TryResolvePendingRouteOption(match, out var entry) - || entry.IsHidden - || match.Route.OptionSchema.IsOptionHidden(entry.ParameterName)) + || !match.Route.OptionSchema.IsEntryDiscoverable(entry)) { return false; } From c241cb9d54f5dfadf76d1831cc4d53058d0def13 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:18:43 -0400 Subject: [PATCH 51/61] refactor: fold the duplicated global-token ownership predicate HelpTextBuilder's IsGlobalTokenOwnedBy and OptionTokenCompletionSource's TryAddGlobalToken independently re-implemented the identical four-part rule (ownership lookup, ReferenceEquals against the expected owner, definition not hidden, this alias spelling not hidden). Moved it to GlobalOptionParser.IsGlobalTokenDiscoverable, next to the BuildCustomTokenOwnership projection it reads, so help and completion share one expression of the rule instead of two. Behavior-preserving: full Repl.Tests and Repl.IntegrationTests suites pass unchanged. --- src/Repl.Core/Help/HelpTextBuilder.Rendering.cs | 12 +----------- .../Options/OptionTokenCompletionSource.cs | 5 +---- src/Repl.Core/Parsing/GlobalOptionParser.cs | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 903ac940..aa901f38 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -625,7 +625,7 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) Option = option, OwnedTokens = option.Aliases .Prepend(option.CanonicalToken) - .Where(token => IsGlobalTokenOwnedBy(token, option, ownership, parsingOptions)) + .Where(token => GlobalOptionParser.IsGlobalTokenDiscoverable(token, option, ownership, parsingOptions)) .ToArray(), }) .Where(static row => row.OwnedTokens.Length > 0) @@ -639,16 +639,6 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) return [.. BuiltInGlobalOptionRows.Concat(customRows)]; } - private static bool IsGlobalTokenOwnedBy( - string token, - GlobalOptionDefinition expectedOwner, - IReadOnlyDictionary ownership, - ParsingOptions parsingOptions) => - ownership.TryGetValue(token, out var actualOwner) - && ReferenceEquals(actualOwner, expectedOwner) - && !actualOwner.IsHidden - && !parsingOptions.IsGlobalOptionAliasHidden(actualOwner, token); - private static TextTableStyle GetCommandRowsStyle(bool useAnsi, AnsiPalette palette) { if (!useAnsi) diff --git a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs index c9c326c9..ce97c387 100644 --- a/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs +++ b/src/Repl.Core/Internal/Options/OptionTokenCompletionSource.cs @@ -100,10 +100,7 @@ private static void TryAddGlobalToken( HashSet dedupe, List results) { - if (!ownership.TryGetValue(token, out var owner) - || !ReferenceEquals(owner, expectedOwner) - || owner.IsHidden - || parsingOptions.IsGlobalOptionAliasHidden(owner, token)) + if (!GlobalOptionParser.IsGlobalTokenDiscoverable(token, expectedOwner, ownership, parsingOptions)) { return; } diff --git a/src/Repl.Core/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index 16023b3b..4c8fd4a2 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -302,6 +302,20 @@ internal static bool TryResolveCustomGlobalDefinition( out GlobalOptionDefinition definition) => BuildCustomTokenOwnership(parsingOptions).TryGetValue(token, out definition!); + // Shared by help and completion: a token only belongs to a discoverable surface when the + // ownership projection still resolves it back to the SAME definition (ReferenceEquals — the + // last-registration-wins rule may have reassigned it to a different one) and neither the + // definition nor this specific alias spelling is hidden. + internal static bool IsGlobalTokenDiscoverable( + string token, + GlobalOptionDefinition expectedOwner, + IReadOnlyDictionary ownership, + ParsingOptions parsingOptions) => + ownership.TryGetValue(token, out var actualOwner) + && ReferenceEquals(actualOwner, expectedOwner) + && !actualOwner.IsHidden + && !parsingOptions.IsGlobalOptionAliasHidden(actualOwner, token); + private static bool TryParseCustomGlobalOption( IReadOnlyList args, ref int index, From 2b781e0ae71c6d3173584712906e2e886093bee5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:24:20 -0400 Subject: [PATCH 52/61] perf: cache the global-token ownership projection per generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildCustomTokenOwnership rebuilt a full Dictionary — one insert per global option plus one per alias — on every call, despite its own comment saying it should run "once for a whole parse/help/completion pass." AutocompleteEngine calls it per keystroke and HelpTextBuilder calls it twice per render, so this was allocation-per-lookup on an interactive path. Moved the cache onto ParsingOptions itself: it owns the mutators that can change token ownership (registration, per-definition and per-alias visibility, and the case-sensitivity setting the ownership dictionary's comparer is built from), so it can null the cache at every one of them without a version counter. GlobalOptionParser's BuildCustomTokenOwnership now delegates to it, so every existing caller benefits without change. Also short-circuit the per-Parse custom-global-values projection to a shared empty instance when nothing was supplied — the overwhelming common case, previously a fresh Dictionary on every invocation regardless. Verified against the full Repl.Tests and Repl.IntegrationTests suites, including the existing runtime-visibility-mutation tests that exercise cache invalidation. --- src/Repl.Core/Parsing/GlobalOptionParser.cs | 36 +++++++--------- src/Repl.Core/ParsingOptions.cs | 48 ++++++++++++++++++++- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/Repl.Core/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index 4c8fd4a2..ab14767d 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -4,6 +4,12 @@ namespace Repl; internal static class GlobalOptionParser { + // The overwhelming majority of invocations supply no custom global option — reuse one empty, + // immutable instance instead of allocating a fresh dictionary on every Parse call to project + // nothing into it. + private static readonly IReadOnlyDictionary> EmptyCustomGlobalValues = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + [SuppressMessage( "Maintainability", "MA0051:Method is too long", @@ -103,10 +109,12 @@ public static GlobalInvocationOptions Parse( remainingIndices.Add(index); } - var readonlyCustomGlobalValues = customGlobalValues.ToDictionary( - pair => pair.Key, - pair => (IReadOnlyList)pair.Value, - StringComparer.OrdinalIgnoreCase); + var readonlyCustomGlobalValues = customGlobalValues.Count == 0 + ? EmptyCustomGlobalValues + : customGlobalValues.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase); return options with { PromptAnswers = promptAnswers, @@ -273,27 +281,13 @@ private static int ClampPageSize(int pageSize, int maxPageSize) => private static void AddResultFlowDiagnostic(List diagnostics, string message) => diagnostics.Add(new ParseDiagnostic(ParseDiagnosticSeverity.Error, message)); - // Builds the parser's authoritative token ownership once for a whole parse/help/completion - // pass. The LAST registered definition wins a token/alias collision: assigning an existing - // dictionary key replaces its owner while preserving the token's first-seen output order. + // The projection itself is cached on ParsingOptions (invalidated by its own mutators), since + // parsing, help, and completion all resolve it — completion on every keystroke. internal static IReadOnlyDictionary BuildCustomTokenOwnership( ParsingOptions parsingOptions) { ArgumentNullException.ThrowIfNull(parsingOptions); - var comparer = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - var ownership = new Dictionary(comparer); - foreach (var definition in parsingOptions.GlobalOptions.Values) - { - ownership[definition.CanonicalToken] = definition; - foreach (var alias in definition.Aliases) - { - ownership[alias] = definition; - } - } - - return ownership; + return parsingOptions.ResolveCustomTokenOwnership(); } internal static bool TryResolveCustomGlobalDefinition( diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index d08f6121..13dd72dc 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -38,6 +38,14 @@ public sealed class ParsingOptions private readonly Dictionary _globalOptions = new(StringComparer.OrdinalIgnoreCase); + // Built once per registration/visibility generation, not per lookup: help renders it twice, + // completion rebuilds it on every keystroke, and it was previously reconstructed — one + // dictionary plus an insert per alias — on every single call. Any mutator that can change + // which token maps to which definition (registration, visibility, or the comparer itself) + // must null this out; a stale cache would silently keep serving a token to its old owner. + private IReadOnlyDictionary? _customTokenOwnershipCache; + private ReplCaseSensitivity _optionCaseSensitivity = ReplCaseSensitivity.CaseSensitive; + /// /// Gets or sets a value indicating whether unknown options are allowed. /// @@ -46,7 +54,15 @@ public sealed class ParsingOptions /// /// Gets or sets option-name case-sensitivity mode. /// - public ReplCaseSensitivity OptionCaseSensitivity { get; set; } = ReplCaseSensitivity.CaseSensitive; + public ReplCaseSensitivity OptionCaseSensitivity + { + get => _optionCaseSensitivity; + set + { + _optionCaseSensitivity = value; + _customTokenOwnershipCache = null; + } + } /// /// Gets or sets a value indicating whether response files (for example: @args.rsp) are expanded. @@ -196,6 +212,7 @@ internal void SetGlobalOptionHidden(string canonicalName, bool isHidden) if (_globalOptions.TryGetValue(canonicalName, out var definition)) { _globalOptions[canonicalName] = definition with { IsHidden = isHidden }; + _customTokenOwnershipCache = null; } } @@ -237,6 +254,7 @@ internal void SetGlobalOptionAliasHidden(string canonicalName, string alias, boo } _globalOptions[canonicalName] = definition with { HiddenAliases = [.. hiddenAliases] }; + _customTokenOwnershipCache = null; } internal bool IsGlobalOptionAliasHidden(GlobalOptionDefinition definition, string token) => @@ -300,6 +318,34 @@ internal void AddGlobalOptionCore( OwnerType: ownerType, IsHidden: isHidden, HiddenAliases: []); + _customTokenOwnershipCache = null; + } + + // The last-registered definition wins a token/alias collision: assigning an existing + // dictionary key replaces its owner while preserving the token's first-seen output order. + // Cached because parsing, help, and completion all need this projection, completion rebuilds + // it on every keystroke, and Values/Aliases are immutable between the mutators above that + // invalidate it. + internal IReadOnlyDictionary ResolveCustomTokenOwnership() + { + if (_customTokenOwnershipCache is { } cached) + { + return cached; + } + + var comparer = ResolveOptionTokenComparer(); + var ownership = new Dictionary(comparer); + foreach (var definition in _globalOptions.Values) + { + ownership[definition.CanonicalToken] = definition; + foreach (var alias in definition.Aliases) + { + ownership[alias] = definition; + } + } + + _customTokenOwnershipCache = ownership; + return ownership; } private static string BuildDuplicateGlobalOptionMessage(string name, Type? existingOwner, Type? newOwner) From 35f94885f8f4cb5041a9066f88a8d1168dc32d72 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:30:53 -0400 Subject: [PATCH 53/61] refactor: make alias-visibility toggling pure and skip no-op writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithAliasVisibility mutated a captured `found` flag from inside a Select projection — correct only because ToArray happens to enumerate eagerly, and would break silently if that materialization were ever removed. Replaced with an explicit loop over Entries. Also make a redundant .HiddenAlias(x, isHidden) call (the alias is already in the requested state) a true no-op: WithAliasVisibility now returns the same schema instance instead of an equivalent copy, and CommandBuilder.UpdateOptionAliasVisibility recognizes that via ReferenceEquals and skips the schema swap and routing invalidation — mirroring the early exit UpdateOptionParameter already had for the same shape of no-op. Verified against the full Repl.Tests and Repl.IntegrationTests suites. --- src/Repl.Core/CommandBuilder.cs | 8 +++++ .../Internal/Options/OptionSchema.cs | 36 +++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index b4e15a74..806290fa 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -277,6 +277,14 @@ private void UpdateOptionAliasVisibility(string targetName, string alias, bool i alias, isHidden, _resolveOptionCaseSensitivity?.Invoke()); + if (ReferenceEquals(candidate, current)) + { + // Already in the requested state: WithAliasVisibility returned the same instance. + // Mirrors UpdateOptionParameter's no-op early exit — no schema swap, no routing + // invalidation, over a call that changed nothing. + return; + } + if (ReferenceEquals(Interlocked.CompareExchange(ref _optionSchema, candidate, current), current)) { _invalidateRouting?.Invoke(isHidden); diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index c2fd2d2e..eae05135 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -132,22 +132,38 @@ public OptionSchema WithAliasVisibility( nameof(alias)); } + // Explicit loop, not a Select with a captured `found` flag: a projection that relies on a + // side effect only works because ToArray happens to enumerate eagerly, and breaks silently + // if that materialization is ever removed. var found = false; - var entries = Entries.Select(entry => + var changed = false; + var entries = new OptionSchemaEntry[Entries.Count]; + for (var i = 0; i < Entries.Count; i++) { - if (!string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) - || !TokensAreEquivalent(entry, alias, currentGlobalCaseSensitivity)) + var entry = Entries[i]; + if (string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) + && TokensAreEquivalent(entry, alias, currentGlobalCaseSensitivity)) { - return entry; + found = true; + changed = entry.IsHidden != isHidden; + entries[i] = changed ? entry with { IsHidden = isHidden } : entry; + } + else + { + entries[i] = entry; } + } - found = true; - return entry with { IsHidden = isHidden }; - }).ToArray(); - return found - ? new OptionSchema(entries, Parameters, GlobalCaseSensitivity) - : throw new KeyNotFoundException( + if (!found) + { + throw new KeyNotFoundException( $"No alias token '{alias}' is registered for option target '{parameterName}'."); + } + + // A no-op call (the alias is already in the requested state) returns this same instance + // rather than an equivalent copy, so the caller's CAS loop can recognize nothing changed + // and skip publishing a schema and invalidating routing over it. + return changed ? new OptionSchema(entries, Parameters, GlobalCaseSensitivity) : this; } private bool TokensAreEquivalent( From 47d68267ffa56b84a71293860380b056f99bd4b1 Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 25 Jul 2026 21:31:37 -0400 Subject: [PATCH 54/61] Fix discovery projection regressions --- src/Repl.Core/CoreReplApp.Execution.cs | 2 +- src/Repl.Core/CoreReplApp.cs | 16 ++ .../Documentation/DocumentationEngine.cs | 22 +-- src/Repl.Core/Documentation/ReplDocOption.cs | 2 - .../Internal/Options/OptionSchema.cs | 179 ++++++++++++------ .../Output/MarkdownOutputTransformer.cs | 10 +- .../Parsing/GlobalInvocationOptions.cs | 3 + src/Repl.Core/Parsing/GlobalOptionParser.cs | 1 + .../Given_DocumentationExport.cs | 53 ++++++ .../Given_HelpDiscovery.cs | 16 ++ src/Repl.Mcp/McpAutomationProjection.cs | 2 +- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 34 ++++ 12 files changed, 258 insertions(+), 82 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 496b2c10..37660fa7 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -480,7 +480,7 @@ private async ValueTask TryHandleContextDeeplinkAsync( match.RemainingTokens, match.Route.OptionSchema, commandParsingOptions, - GlobalOptionParser.BuildCustomTokenOwnership(_options.Parsing)); + globalOptions.CustomGlobalTokenOwnership); if (parsedOptions.HasErrors) { var firstError = parsedOptions.Diagnostics diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index dbb3c0b3..d3e4f22a 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -137,7 +137,23 @@ public CoreReplApp Use(Func middlewar public CoreReplApp Options(Action configure) { ArgumentNullException.ThrowIfNull(configure); + var previousCaseSensitivity = _options.Parsing.OptionCaseSensitivity; + var previousGlobalOptions = _options.Parsing.GlobalOptions.ToDictionary( + static pair => pair.Key, + static pair => pair.Value, + StringComparer.OrdinalIgnoreCase); + configure(_options); + + var globalDiscoveryChanged = previousGlobalOptions.Count != _options.Parsing.GlobalOptions.Count + || previousGlobalOptions.Any(pair => + !_options.Parsing.GlobalOptions.TryGetValue(pair.Key, out var current) + || !ReferenceEquals(pair.Value, current)); + if (previousCaseSensitivity != _options.Parsing.OptionCaseSensitivity || globalDiscoveryChanged) + { + InvalidateRouting(isVisibilityRetraction: true); + } + return this; } diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index f7eb46be..1ad7cf52 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -403,10 +403,8 @@ private bool ShouldIncludeDocumentationOption( } var displayToken = schema.ResolveDisplayToken(parameterName); - var hasReachableToken = schema.Entries.Any(entry => - schema.IsAliasDiscoverable(entry) - && string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) - && !customGlobalOwnership.ContainsKey(entry.Token)); + var hasReachableToken = schema.ResolveDiscoverableAliases(parameterName) + .Any(entry => !customGlobalOwnership.ContainsKey(entry.Token)); if (displayToken is null || hasReachableToken) { return true; @@ -517,11 +515,8 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( IReadOnlyDictionary customGlobalOwnership) { var displayToken = schema.ResolveDisplayToken(property.Name); - var entries = schema.Entries - .Where(entry => - schema.IsAliasDiscoverable(entry) - && string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase) - && !customGlobalOwnership.ContainsKey(entry.Token)) + var entries = schema.ResolveDiscoverableAliases(property.Name) + .Where(entry => !customGlobalOwnership.ContainsKey(entry.Token)) .ToArray(); var aliases = entries .Where(entry => entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) @@ -560,7 +555,6 @@ private static ReplDocOption BuildDocumentationOptionFromProperty( { IsHidden = schema.IsOptionHidden(property.Name), IsAutomationHidden = schema.IsOptionAutomationHidden(property.Name), - IsNamedTokenReachable = aliases.Length > 0, }; } @@ -571,11 +565,8 @@ private ReplDocOption BuildDocumentationOption( IReadOnlyDictionary customGlobalOwnership) { var displayToken = schema.ResolveDisplayToken(parameter.Name!); - var entries = schema.Entries - .Where(entry => - schema.IsAliasDiscoverable(entry) - && string.Equals(entry.ParameterName, parameter.Name, StringComparison.OrdinalIgnoreCase) - && !customGlobalOwnership.ContainsKey(entry.Token)) + var entries = schema.ResolveDiscoverableAliases(parameter.Name!) + .Where(entry => !customGlobalOwnership.ContainsKey(entry.Token)) .ToArray(); var aliases = entries .Where(entry => entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) @@ -613,7 +604,6 @@ private ReplDocOption BuildDocumentationOption( { IsHidden = schema.IsOptionHidden(parameter.Name!), IsAutomationHidden = schema.IsOptionAutomationHidden(parameter.Name!), - IsNamedTokenReachable = aliases.Length > 0, }; } diff --git a/src/Repl.Core/Documentation/ReplDocOption.cs b/src/Repl.Core/Documentation/ReplDocOption.cs index 286b4354..4c70ccfe 100644 --- a/src/Repl.Core/Documentation/ReplDocOption.cs +++ b/src/Repl.Core/Documentation/ReplDocOption.cs @@ -39,6 +39,4 @@ public sealed record ReplDocOption( /// public bool IsAutomationHidden { get; init; } - /// Whether automation can reconstruct this value through an ordinary named option token. - internal bool IsNamedTokenReachable { get; init; } = true; } diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 2a88e7d4..fb2c8d30 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -48,8 +48,8 @@ public OptionSchema( // the same result. A visibility change does not mutate a schema — it publishes a new // one (see WithParameter) — so these caches can never go stale. private OptionSchemaParameter[]? _discoverableParameters; - private OptionSchemaEntry[]? _discoverableSensitiveEntries; - private OptionSchemaEntry[]? _discoverableInsensitiveEntries; + private DiscoveryProjection? _sensitiveDiscovery; + private DiscoveryProjection? _insensitiveDiscovery; /// /// Parameters that discovery surfaces may advertise: option-bearing and not hidden. @@ -68,19 +68,8 @@ .. Parameters.Values.Where(parameter => /// value aliases, negated flags), so filtering here keeps every token of a hidden option /// out of completion, not just its canonical form. /// - public IReadOnlyList DiscoverableEntries - { - get - { - var globalCaseSensitivity = _resolveGlobalCaseSensitivity(); - return globalCaseSensitivity == ReplCaseSensitivity.CaseInsensitive - ? _discoverableInsensitiveEntries ??= BuildDiscoverableEntries(globalCaseSensitivity) - : _discoverableSensitiveEntries ??= BuildDiscoverableEntries(globalCaseSensitivity); - } - } - - private string[]? _discoverableSensitiveTokens; - private string[]? _discoverableInsensitiveTokens; + public IReadOnlyList DiscoverableEntries => + ResolveDiscoveryProjection().DiscoverableEntries; /// /// minus the tokens of hidden options. @@ -90,16 +79,8 @@ public IReadOnlyList DiscoverableEntries /// '--secret'?" turns a validation error into a way to enumerate hidden options by probing at /// small edit distance. Parsing keeps the full set, so a hidden option still binds when supplied. /// - public IReadOnlyCollection DiscoverableTokens - { - get - { - var globalCaseSensitivity = _resolveGlobalCaseSensitivity(); - return globalCaseSensitivity == ReplCaseSensitivity.CaseInsensitive - ? _discoverableInsensitiveTokens ??= BuildDiscoverableTokens(globalCaseSensitivity) - : _discoverableSensitiveTokens ??= BuildDiscoverableTokens(globalCaseSensitivity); - } - } + public IReadOnlyCollection DiscoverableTokens => + ResolveDiscoveryProjection().DiscoverableTokens; /// /// Whether the named parameter is hidden from discovery. Unknown names are not hidden: @@ -181,47 +162,113 @@ private bool TokensAreEquivalent( return string.Equals(entry.Token, token, comparison); } - private OptionSchemaEntry[] BuildDiscoverableEntries(ReplCaseSensitivity globalCaseSensitivity) => - [ - .. Entries.Where(entry => IsEntryDiscoverable(entry, globalCaseSensitivity)), - ]; + private DiscoveryProjection ResolveDiscoveryProjection() + { + var globalCaseSensitivity = _resolveGlobalCaseSensitivity(); + return globalCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? _insensitiveDiscovery ??= BuildDiscoveryProjection(globalCaseSensitivity) + : _sensitiveDiscovery ??= BuildDiscoveryProjection(globalCaseSensitivity); + } - private string[] BuildDiscoverableTokens(ReplCaseSensitivity globalCaseSensitivity) => - [ - .. BuildDiscoverableEntries(globalCaseSensitivity) - .Select(entry => entry.Token) - .Distinct(StringComparer.Ordinal), - ]; + private DiscoveryProjection BuildDiscoveryProjection(ReplCaseSensitivity globalCaseSensitivity) + { + var exactHidden = new HashSet(AliasVisibilityKeyComparer.Ordinal); + var insensitiveHidden = new HashSet(AliasVisibilityKeyComparer.OrdinalIgnoreCase); + foreach (var hidden in Entries.Where(static entry => entry.IsHidden)) + { + var key = AliasVisibilityKey.From(hidden); + var effectiveCaseSensitivity = hidden.CaseSensitivity ?? globalCaseSensitivity; + _ = effectiveCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? insensitiveHidden.Add(key) + : exactHidden.Add(key); + } + + var canonicalEntries = ResolveNamedEntries().Values.ToHashSet(); + var visibleAliases = new HashSet(); + var visibleAliasesByParameter = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var discoverableEntries = new List(Entries.Count); + foreach (var entry in Entries) + { + var key = AliasVisibilityKey.From(entry); + var aliasIsVisible = !entry.IsHidden + && (canonicalEntries.Contains(entry) + || (!exactHidden.Contains(key) && !insensitiveHidden.Contains(key))); + if (!aliasIsVisible) + { + continue; + } + + visibleAliases.Add(entry); + if (!visibleAliasesByParameter.TryGetValue(entry.ParameterName, out var parameterAliases)) + { + parameterAliases = []; + visibleAliasesByParameter.Add(entry.ParameterName, parameterAliases); + } + parameterAliases.Add(entry); + if (!IsOptionHidden(entry.ParameterName)) + { + discoverableEntries.Add(entry); + } + } + + var entries = discoverableEntries.ToArray(); + return new DiscoveryProjection( + entries, + [.. entries.Select(static entry => entry.Token).Distinct(StringComparer.Ordinal)], + visibleAliases, + entries.ToHashSet(), + visibleAliasesByParameter.ToDictionary( + static pair => pair.Key, + static pair => (IReadOnlyList)pair.Value.ToArray(), + StringComparer.OrdinalIgnoreCase)); + } + + internal IReadOnlyList ResolveDiscoverableAliases(string parameterName) => + ResolveDiscoveryProjection().VisibleAliasesByParameter.GetValueOrDefault(parameterName) ?? []; internal bool IsEntryDiscoverable(OptionSchemaEntry entry) => - !IsOptionHidden(entry.ParameterName) && IsAliasDiscoverable(entry); + ResolveDiscoveryProjection().DiscoverableEntrySet.Contains(entry); internal bool IsAliasDiscoverable(OptionSchemaEntry entry) => - IsAliasDiscoverable(entry, _resolveGlobalCaseSensitivity()); - - private bool IsEntryDiscoverable(OptionSchemaEntry entry, ReplCaseSensitivity globalCaseSensitivity) => - !IsOptionHidden(entry.ParameterName) && IsAliasDiscoverable(entry, globalCaseSensitivity); + ResolveDiscoveryProjection().VisibleAliasSet.Contains(entry); + + private sealed record DiscoveryProjection( + OptionSchemaEntry[] DiscoverableEntries, + string[] DiscoverableTokens, + HashSet VisibleAliasSet, + HashSet DiscoverableEntrySet, + IReadOnlyDictionary> VisibleAliasesByParameter); + + private readonly record struct AliasVisibilityKey( + string ParameterName, + OptionSchemaTokenKind TokenKind, + string? InjectedValue, + string Token) + { + public static AliasVisibilityKey From(OptionSchemaEntry entry) => + new(entry.ParameterName, entry.TokenKind, entry.InjectedValue, entry.Token); + } - private bool IsAliasDiscoverable(OptionSchemaEntry entry, ReplCaseSensitivity globalCaseSensitivity) + private sealed class AliasVisibilityKeyComparer(StringComparer tokenComparer) : IEqualityComparer { - if (entry.IsHidden) - { - return false; - } + public static AliasVisibilityKeyComparer Ordinal { get; } = new(StringComparer.Ordinal); + public static AliasVisibilityKeyComparer OrdinalIgnoreCase { get; } = new(StringComparer.OrdinalIgnoreCase); + + public bool Equals(AliasVisibilityKey left, AliasVisibilityKey right) => + left.TokenKind == right.TokenKind + && string.Equals(left.ParameterName, right.ParameterName, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.InjectedValue, right.InjectedValue, StringComparison.Ordinal) + && tokenComparer.Equals(left.Token, right.Token); - // HiddenAliases controls secondary aliases only; a later global-mode change must not - // turn a secondary alias into a way to withdraw the canonical token. - if (ReferenceEquals(entry, FindNamedEntry(entry.ParameterName))) + public int GetHashCode(AliasVisibilityKey value) { - return true; + var hash = new HashCode(); + hash.Add(value.ParameterName, StringComparer.OrdinalIgnoreCase); + hash.Add(value.TokenKind); + hash.Add(value.InjectedValue, StringComparer.Ordinal); + hash.Add(value.Token, tokenComparer); + return hash.ToHashCode(); } - - return !Entries.Any(hidden => - hidden.IsHidden - && string.Equals(hidden.ParameterName, entry.ParameterName, StringComparison.OrdinalIgnoreCase) - && hidden.TokenKind == entry.TokenKind - && string.Equals(hidden.InjectedValue, entry.InjectedValue, StringComparison.Ordinal) - && TokensAreEquivalent(hidden, entry.Token, globalCaseSensitivity)); } public IReadOnlyList ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity) @@ -264,17 +311,27 @@ public ReplArity ResolveParameterArity(string parameterName) => public string? ResolveDisplayToken(string parameterName) => FindNamedEntry(parameterName)?.Token; - private OptionSchemaEntry? FindNamedEntry(string parameterName) + private Dictionary? _namedEntries; + + private OptionSchemaEntry? FindNamedEntry(string parameterName) => + ResolveNamedEntries().GetValueOrDefault(parameterName); + + private Dictionary ResolveNamedEntries() { + if (_namedEntries is { } cached) + { + return cached; + } + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in Entries) { - if (string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) - && entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) + if (entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) { - return entry; + entries.TryAdd(entry.ParameterName, entry); } } - return null; + return _namedEntries = entries; } } diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index 1aa591d2..35037bca 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -488,7 +488,15 @@ private static void AppendOptions(StringBuilder builder, IReadOnlyList alias.Token)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var displayTokens = tokens.Length == 0 + ? $"`--{option.Name}`" + : string.Join(", ", tokens.Select(static token => $"`{token}`")); + builder.Append(" - ").Append(displayTokens).Append(" (") .Append(option.Type).Append(')'); // Commands print their own Hidden line, and the structured formats get these flags for diff --git a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs index 4baaaba0..86efd551 100644 --- a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs +++ b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs @@ -21,6 +21,9 @@ internal sealed record GlobalInvocationOptions( public IReadOnlyDictionary> CustomGlobalNamedOptions { get; init; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + internal IReadOnlyDictionary CustomGlobalTokenOwnership { get; init; } = + new Dictionary(StringComparer.Ordinal); + public IReadOnlyList Diagnostics { get; init; } = []; // The original input index of each surviving token in . diff --git a/src/Repl.Core/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index 16023b3b..d6f4815f 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -111,6 +111,7 @@ public static GlobalInvocationOptions Parse( { PromptAnswers = promptAnswers, CustomGlobalNamedOptions = readonlyCustomGlobalValues, + CustomGlobalTokenOwnership = customTokenMap, Diagnostics = diagnostics, RemainingTokenIndices = remainingIndices, }; diff --git a/src/Repl.IntegrationTests/Given_DocumentationExport.cs b/src/Repl.IntegrationTests/Given_DocumentationExport.cs index 8ee2cbcd..7bc91474 100644 --- a/src/Repl.IntegrationTests/Given_DocumentationExport.cs +++ b/src/Repl.IntegrationTests/Given_DocumentationExport.cs @@ -106,6 +106,59 @@ public void When_ExportingExactCommandAsMarkdown_Then_HiddenOptionsAreFlagged() markdown.Text.Should().Contain("`--environment`"); } + [TestMethod] + [Description("Markdown renders the surviving invocable reverse alias when global precedence removes the ordinary route token, rather than inventing the unreachable canonical spelling.")] + public void When_OnlyReverseAliasRemainsReachable_Then_MarkdownRendersThatAlias() + { + var sut = ReplApp.Create().UseDocumentationExport(); + sut.Options(options => + { + options.Parsing.AddGlobalOption("force"); + options.Parsing.GlobalOption("force").Hidden(); + }); + sut.Map( + "deploy", + static string ([ReplOption(ReverseAliases = ["--no-force"])] bool force = true) => force.ToString()); + + var markdown = ConsoleCaptureHelper.Capture(() => + sut.Run(["doc", "export", "deploy", "--markdown", "--no-logo"])); + + markdown.ExitCode.Should().Be(0, markdown.Text); + markdown.Text.Should().Contain("`--no-force`"); + markdown.Text.Should().NotContain("`--force`"); + } + + [TestMethod] + [Description("Exact-target inventory retains a wholly hidden route option even when a hidden global owns its only token; every export format identifies it and preserves the hidden flag.")] + public void When_HiddenGlobalOwnsWhollyHiddenOptionToken_Then_ExactExportsRetainTheOption() + { + var sut = ReplApp.Create().UseDocumentationExport(); + sut.Options(options => + { + options.Parsing.AddGlobalOption("internal-mode"); + options.Parsing.GlobalOption("internal-mode").Hidden(); + }); + sut.Map( + "deploy", + static string ([ReplOption(Name = "internal-mode")] bool internalMode = false) => internalMode.ToString()) + .WithOption("internalMode", static option => option.Hidden()); + + var modelOption = sut.CreateDocumentationModel("deploy").Commands.Single().Options.Single(); + var json = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--json", "--no-logo"])); + var yaml = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--yaml", "--no-logo"])); + var xml = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--xml", "--no-logo"])); + var markdown = ConsoleCaptureHelper.Capture(() => sut.Run(["doc", "export", "deploy", "--markdown", "--no-logo"])); + + modelOption.Name.Should().Be("internal-mode"); + modelOption.IsHidden.Should().BeTrue(); + foreach (var export in new[] { json, yaml, xml, markdown }) + { + export.ExitCode.Should().Be(0, export.Text); + export.Text.Should().Contain("internal-mode"); + export.Text.Should().ContainEquivalentOf("hidden"); + } + } + [TestMethod] [Description("The visibility flags are new members on a serialized public record, and yaml and xml emit that record wholesale rather than field by field. Only json and markdown had coverage, so this exercises the two formats that would have broken silently.")] public void When_ExportingExactCommandAsYamlOrXml_Then_VisibilityFlagsSerialize() diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index fe801c6b..5ed1e6e8 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -649,6 +649,22 @@ public void When_GlobalOwnsOrdinaryFlagButReverseAliasRemains_Then_HumanDocument help.Text.Should().Contain("--no-force").And.NotContain("--force"); option.Aliases.Should().BeEmpty(); option.ReverseAliases.Should().ContainSingle().Which.Should().Be("--no-force"); + var publicRoundTrip = new Repl.Documentation.ReplDocOption( + option.Name, + option.Type, + option.Required, + option.Description, + option.Aliases, + option.ReverseAliases, + option.ValueAliases, + option.EnumValues, + option.DefaultValue) + { + IsHidden = option.IsHidden, + IsAutomationHidden = option.IsAutomationHidden, + }; + option.Equals(publicRoundTrip).Should().BeTrue("MCP-only derivations must not alter public record equality"); + option.GetHashCode().Should().Be(publicRoundTrip.GetHashCode()); invocation.ExitCode.Should().Be(0, invocation.Text); invocation.Text.Should().Contain("False"); } diff --git a/src/Repl.Mcp/McpAutomationProjection.cs b/src/Repl.Mcp/McpAutomationProjection.cs index c7ed19c1..cf72059a 100644 --- a/src/Repl.Mcp/McpAutomationProjection.cs +++ b/src/Repl.Mcp/McpAutomationProjection.cs @@ -53,5 +53,5 @@ private static bool HasUnavailableOption(ReplDocCommand command) => command.Options.Any(static option => IsUnavailable(option)); private static bool IsUnavailable(ReplDocOption option) => - option.IsAutomationHidden || !option.IsNamedTokenReachable; + option.IsAutomationHidden || option.Aliases.Count == 0; } diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index eef143a6..295d2efc 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -591,6 +591,40 @@ public async Task When_OnlyReverseAliasRemainsReachable_Then_McpOmitsTheSemantic .Should().Contain("True"); } + [TestMethod] + [Description("Changing inherited option casing after an MCP snapshot retracts aliases that become equivalent to a hidden alias; the next list and call must use the rebuilt schema rather than the stale adapter.")] + public async Task When_OptionCaseModeChangesAfterInitialList_Then_McpRetractsNewlyHiddenField() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Options(options => + { + options.Parsing.AddGlobalOption("tenant"); + options.Parsing.GlobalOption("tenant").Hidden(); + }); + app.Map( + "deploy", + static string ([ReplOption(Name = "tenant", Aliases = ["--ACCOUNT"], HiddenAliases = ["--account"])] string? tenant = null) => tenant ?? "none"); + }); + + var before = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + before.Should().Contain("tenant"); + + fixture.App.Options(options => + options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + + var after = await ReadDeployPropertiesAsync(fixture).ConfigureAwait(false); + var call = await fixture.Client.CallToolAsync( + toolName: "deploy", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["tenant"] = "north", + }).ConfigureAwait(false); + + after.Should().NotContain("tenant"); + call.IsError.Should().BeTrue(); + } + [TestMethod] [Description("The MCP half of the AutomationHidden pair: the advertised tool schema omits the option, and because the schema and the accepted argument list are built from the same option list, tools/call rejects it too. The help half is asserted separately, where the same option stays listed and binds.")] public async Task When_CommandOptionIsAutomationHidden_Then_McpOmitsItAndRejectsIt() From 6f77389e0b8ffc90c9af368826a77b5e7282a48f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:37:25 -0400 Subject: [PATCH 55/61] fix: bound the MCP discovery-notification send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendNotificationSafeAsync sent with CancellationToken.None. Nothing awaits this fire-and-forget task, so a stuck stdio peer would hang it indefinitely — one more never-completing task per routing invalidation, forever. Bound it to a 5s timeout instead. Left the debounce timer's reset-with-no-ceiling alone: a genuinely continuous stream of invalidations is a narrow, unusual scenario, and McpServerHandler's snapshot/versioning logic is under active concurrent development elsewhere on this branch right now — safer to leave that specific mechanism alone than risk a subtle interaction with it. --- src/Repl.Mcp/McpServerHandler.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index ee97c0dc..3f4354c2 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -46,6 +46,11 @@ internal sealed class McpServerHandler private int _compatibilityIntroServed; private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); + // Notifications are fire-and-forget best-effort — a stuck stdio peer must not hang this + // indefinitely, since nothing awaits it and it would otherwise pile up one task per + // invalidation forever. + private static readonly TimeSpan NotificationSendTimeout = TimeSpan.FromSeconds(5); + public McpServerHandler( ICoreReplApp app, ReplMcpServerOptions options, @@ -587,7 +592,8 @@ private async Task SendNotificationSafeAsync(string method) return; } - await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(NotificationSendTimeout); + await server.SendNotificationAsync(method, timeoutCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { From 8f322e979a5e56679be36b641fde2c3e5d2cdd18 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:47:54 -0400 Subject: [PATCH 56/61] fix: reject two global options that render as the same token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different Names ("tenant" and "--tenant") can normalize to the identical canonical token. The existing duplicate check only compares by the raw Name key, so both registrations succeeded, leaving ResolveGlobalOptionKey's FirstOrDefault to arbitrarily pick whichever definition happens to enumerate first whenever GlobalOption(name) is later called by that token — GlobalOption("tenant").Hidden() could silently hide the wrong definition, leaving the token it actually owns still advertised. Reject the second registration outright, the same way an exact-name duplicate already is. --- src/Repl.Core/ParsingOptions.cs | 13 +++++++++++++ src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 13dd72dc..4837ff28 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -300,6 +300,19 @@ internal void AddGlobalOptionCore( throw new InvalidOperationException(BuildDuplicateGlobalOptionMessage(name, existing.OwnerType, ownerType)); } + // Two different Names ("tenant" and "--tenant") can normalize to the identical canonical + // token. Left unrejected, GlobalOption(name) resolves that token to whichever definition + // happens to enumerate first — an ambiguity, not a deterministic choice — so ANY caller + // mutating "the" option by that token could silently be mutating the wrong one. + var canonicalCollision = _globalOptions.Values.FirstOrDefault(candidate => + string.Equals(candidate.CanonicalToken, normalizedCanonical, StringComparison.OrdinalIgnoreCase)); + if (canonicalCollision is not null) + { + throw new InvalidOperationException( + $"A global option named '{canonicalCollision.Name}' already renders as the same token " + + $"'{normalizedCanonical}' that registering '{name}' would produce."); + } + var tokenComparer = ResolveOptionTokenComparer(); var normalizedAliases = (aliases ?? []) .Where(alias => !string.IsNullOrWhiteSpace(alias)) diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index d87155d3..dfa0962c 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -586,6 +586,19 @@ public void When_RegisteredWithStringTypeName_Email_Then_ResolvesAsString() parsing.GlobalOptions["contact"].ValueType.Should().Be(typeof(string)); } + [TestMethod] + [Description("Two different Names ('tenant' and '--tenant') can normalize to the identical canonical token. Left unrejected, GlobalOption(name) would resolve that token to whichever definition happens to enumerate first — an ambiguity, not a deterministic choice — so registering the second one must be rejected the same way an exact-name duplicate already is.")] + public void When_TwoGlobalOptionsNormalizeToTheSameCanonicalToken_Then_TheSecondRegistrationThrows() + { + var sut = new ParsingOptions(); + sut.AddGlobalOption("tenant"); + + var register = () => sut.AddGlobalOption("--tenant"); + + register.Should().Throw() + .WithMessage("*tenant*--tenant*"); + } + private sealed class TestTypedOptions { public string? Tenant { get; set; } From 1e006a805a69b19718dd47ff7649d6b8bdb1cf95 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:50:31 -0400 Subject: [PATCH 57/61] docs: add a CHANGELOG for this PR's consumer-facing surface No CHANGELOG previously existed. A commit subject marked with a Conventional Commits "!" (e.g. the WithOption consolidation) is not consumer documentation, and this release adds public API (.Hidden, .HiddenAlias, HiddenAliases), changes what doc export --json serializes, and introduces a lockstep-upgrade requirement between Repl.Core and Repl.Defaults that a consumer bumping only one package would otherwise discover as a runtime MissingMethodException. Versions are assigned by Nerdbank.GitVersioning at pack time, so this groups changes under "Unreleased" rather than a specific version number. --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..57bcb2fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +Notable consumer-facing changes to the Repl packages. Versions are assigned automatically by +Nerdbank.GitVersioning at pack time; this file groups changes by theme instead of by release. + +## Unreleased + +### Added — option visibility + +- `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`) + hides an option's canonical token, aliases, description, default, and value candidates from help, + generated documentation, interactive/shell completion, and MCP tool schemas. The option remains a + fully parsable, invocable part of the command line — hiding is a discovery filter, not access + control. Available for direct command-handler parameters, options-group properties, manually + registered global options (`ParsingOptions.GlobalOption(name).Hidden()`), and typed global options. +- `.HiddenAlias(alias, isHidden = true)` and `[ReplOption(HiddenAliases = [...])]` mark specific + legacy/deprecated token spellings as parser-only: the canonical token and any current aliases stay + discoverable, while the hidden alias keeps binding from the CLI/REPL for backward compatibility. +- `doc export` (and `docs `) reports `isHidden` / `isAutomationHidden` per option so an + app author can inventory what a given command hides. Aggregate documentation (no target path) and + MCP's `tools/list` always omit hidden options entirely — see `docs/commands.md` for the full + visibility matrix. +- A hidden option must remain omittable for every provider that can build a discovery surface. + Hiding a required options-group property fails immediately at `Map` time. Hiding a required direct + handler parameter defers that check to the first time discovery runs against a real service + provider (aggregate documentation build or MCP startup), since a DI/synthesized-progress fallback + is only knowable once one exists — see the "Provider-aware requiredness" section of + `docs/commands.md`. + +### Changed — breaking + +- `WithOption(name, configure)` is now the only fluent entry point for configuring an existing + option's metadata (visibility included). This lands within the same change that introduces it — + no previously published `Option(...)` API is removed by this release. + +### Compatibility notes + +- `doc export --json` (and other structured documentation exports) now unconditionally include the + `isHidden` and `isAutomationHidden` fields on every option. A consumer validating that output + against a closed schema (`additionalProperties: false`) will need to allow these two additive + fields. +- The historical six-parameter `ParsingOptions.AddGlobalOptionCore` descriptor is preserved as a + distinct overload (not folded into a defaulted parameter) so an already-compiled `Repl.Defaults` + binary continues to work against a newer `Repl.Core`. The reverse is not guaranteed: this release's + `Repl.Defaults` calls APIs that only exist in this release's `Repl.Core`, so upgrading only one of + the two packages independently is not supported — upgrade them together. From a1b07c5c177bc76a306db23383c1cc169357de12 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 21:56:25 -0400 Subject: [PATCH 58/61] test: remove a reflection-only existence check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asserted only that HiddenAliases/HiddenAlias exist via reflection — would pass even if HiddenAlias were a no-op. The very next test, When_RouteAliasIsHidden_Then_OnlyParsingRetainsIt, already exercises both APIs behaviorally (parsing, help, and documentation), so this one added no coverage the neighboring test doesn't already provide. --- src/Repl.IntegrationTests/Given_HelpDiscovery.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 0ffd2c3c..13031b6a 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -84,15 +84,6 @@ public void When_RequestingCommandHelp_Then_UsageAndDescriptionAreRendered() output.Text.Should().Contain("Description: List contacts"); } - [TestMethod] - [Description("Legacy fallback needs token-level visibility rather than hiding the whole option: attributes and both fluent option builders must expose a hidden-alias contract.")] - public void When_InspectingOptionVisibilityApis_Then_HiddenAliasConfigurationIsAvailable() - { - typeof(ReplOptionAttribute).GetProperty("HiddenAliases").Should().NotBeNull(); - typeof(OptionBuilder).GetMethod("HiddenAlias").Should().NotBeNull(); - typeof(GlobalOptionBuilder).GetMethod("HiddenAlias").Should().NotBeNull(); - } - [TestMethod] [Description("A hidden legacy alias remains a parser fallback but never appears in command help, aggregate documentation, or typo suggestions; attribute and fluent forms keep the canonical token visible.")] public void When_RouteAliasIsHidden_Then_OnlyParsingRetainsIt() From 694651d964cc7080743acbfb9ed166e74d458e67 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 23:40:50 -0400 Subject: [PATCH 59/61] fix: accumulate visibility changes across equivalent aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithAliasVisibility overwrote 'changed' on every matching entry instead of accumulating it. When two parser-equivalent aliases match the same request (e.g. --ACCOUNT and --account once parsing turns case-insensitive) and the first genuinely changes while a later one is already in the requested state, the later entry's no-op reset 'changed' to false — the method then returned the original schema unchanged, discarding the first entry's real update even though the constructed entries array held it correctly. Track each entry's own comparison result and OR it into the accumulator instead of assigning it directly. Found via automated PR review (chatgpt-codex-connector) on this same branch, in code I introduced myself in 35f9488. --- .../Internal/Options/OptionSchema.cs | 9 +++-- src/Repl.Tests/Given_OptionSchema.cs | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/Repl.Tests/Given_OptionSchema.cs diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs index 5a38d8f8..d5362530 100644 --- a/src/Repl.Core/Internal/Options/OptionSchema.cs +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -143,8 +143,13 @@ public OptionSchema WithAliasVisibility( && TokensAreEquivalent(entry, alias, currentGlobalCaseSensitivity)) { found = true; - changed = entry.IsHidden != isHidden; - entries[i] = changed ? entry with { IsHidden = isHidden } : entry; + // Accumulate with OR, not a plain assignment: multiple parser-equivalent aliases + // (e.g. --ACCOUNT and --account under case-insensitive mode) can match the same + // request. An earlier entry's real change must not be discarded just because a + // later equivalent entry happens to already be in the requested state. + var entryChanged = entry.IsHidden != isHidden; + changed |= entryChanged; + entries[i] = entryChanged ? entry with { IsHidden = isHidden } : entry; } else { diff --git a/src/Repl.Tests/Given_OptionSchema.cs b/src/Repl.Tests/Given_OptionSchema.cs new file mode 100644 index 00000000..b7497235 --- /dev/null +++ b/src/Repl.Tests/Given_OptionSchema.cs @@ -0,0 +1,34 @@ +using Repl.Internal.Options; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_OptionSchema +{ + [TestMethod] + [Description("Given two parser-equivalent aliases under case-insensitive mode where the first needs to change and the second is already in the requested state, WithAliasVisibility must not let the second entry's no-op reset the accumulated 'changed' flag: the returned schema must reflect the first entry's real change instead of being discarded as a no-op.")] + public void When_AnEarlierEquivalentAliasChangesButALaterOneAlreadyMatches_Then_TheChangeIsNotLost() + { + var caseSensitivity = ReplCaseSensitivity.CaseSensitive; + var schema = new OptionSchema( + [ + new OptionSchemaEntry("--tenant", "tenant", OptionSchemaTokenKind.NamedOption, ReplArity.ZeroOrOne), + new OptionSchemaEntry("--ACCOUNT", "tenant", OptionSchemaTokenKind.NamedOption, ReplArity.ZeroOrOne, IsHidden: false), + new OptionSchemaEntry("--account", "tenant", OptionSchemaTokenKind.NamedOption, ReplArity.ZeroOrOne, IsHidden: true), + ], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tenant"] = new OptionSchemaParameter("tenant", typeof(string), ReplParameterMode.OptionOnly), + }, + () => caseSensitivity); + + // Both --ACCOUNT and --account become equivalent to the requested alias "--ACCOUNT" once + // case sensitivity turns insensitive, even though they were registered as distinct tokens. + caseSensitivity = ReplCaseSensitivity.CaseInsensitive; + var updated = schema.WithAliasVisibility("tenant", "--ACCOUNT", isHidden: true); + + caseSensitivity = ReplCaseSensitivity.CaseSensitive; + updated.Entries.Should().ContainSingle(entry => entry.Token == "--ACCOUNT") + .Which.IsHidden.Should().BeTrue("the first matching entry genuinely changed and must not be lost because a later equivalent entry was already hidden"); + } +} From 910cef0ae56b2cb24269f211312f293b9dfa21c6 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 23:41:54 -0400 Subject: [PATCH 60/61] docs: make null-HiddenAliases handling explicit, not incidental ReplOptionAttribute.HiddenAliases can be explicitly set to null (valid attribute metadata despite the nullable warning), and optionAttribute?.HiddenAliases.Contains(alias, ...) reads as a null dereference in that case. Investigated with a red test expecting a crash first: it did not reproduce. .Contains here resolves to the MemoryExtensions span overload, and the implicit array-to-ReadOnlySpan conversion turns a null array into Span.Empty rather than throwing, so the call silently (and correctly) treats the null the same as an empty array today. Applied the explicit ?? [] guard anyway, matching the sibling consumption site two lines below and every other HiddenAliases reader in this file: relying on the null-to-empty-span conversion to get the right answer is fragile and non-obvious, even though it isn't presently a bug. Kept both regression tests (direct parameter and options-group property) asserting the alias stays registered and visible, since that is the behavior worth pinning either way. --- .../Internal/Options/OptionSchemaBuilder.cs | 4 +-- .../Given_HelpDiscovery.cs | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index cb2f600e..6d0985a3 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -177,7 +177,7 @@ private static void AppendOptionAliases( { // Preserve case-distinct aliases in the parse schema so inherited visibility can be // reevaluated if the global case mode changes after mapping. - if (optionAttribute?.HiddenAliases.Contains(alias, StringComparer.Ordinal) == true) + if ((optionAttribute?.HiddenAliases ?? []).Contains(alias, StringComparer.Ordinal)) { continue; } @@ -524,7 +524,7 @@ private static void AppendPropertyOptionAliases( { // Preserve case-distinct aliases in the parse schema so inherited visibility can be // reevaluated if the global case mode changes after mapping. - if (optionAttribute?.HiddenAliases.Contains(alias, StringComparer.Ordinal) == true) + if ((optionAttribute?.HiddenAliases ?? []).Contains(alias, StringComparer.Ordinal)) { continue; } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 97046fe4..aa214866 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -124,6 +124,40 @@ static string ([ReplOption(Name = "tenant", Aliases = ["--account", "--ACCOUNT"] option.Aliases.Should().Contain(["--tenant", "--ACCOUNT"]); option.Aliases.Should().NotContain("--account"); } + + [TestMethod] + [Description("ReplOptionAttribute.HiddenAliases can be explicitly set to null (valid attribute metadata despite the nullable warning). Mapping a command with at least one visible alias must not depend on the implicit null-array-to-Span conversion happening to make '.Contains' return false — that reads as a dereference of a null array and should stay explicit (?? []), matching every other consumer of this field.")] + public void When_HiddenAliasesIsExplicitlyNull_Then_MappingDoesNotThrowAndAliasStaysVisible() + { + var sut = ReplApp.Create(); + sut.Map( + "deploy", + static string ([ReplOption(Aliases = ["--tenant-name"], HiddenAliases = null!)] string? tenant = null) => tenant ?? "none"); + + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + option.Aliases.Should().Contain("--tenant-name"); + } + + [TestMethod] + [Description("Same null-HiddenAliases contract for an options-group property, which builds its schema entries through a separate code path from direct handler parameters.")] + public void When_GroupPropertyHiddenAliasesIsExplicitlyNull_Then_MappingDoesNotThrowAndAliasStaysVisible() + { + var sut = ReplApp.Create(); + sut.Map("deploy", (NullHiddenAliasesOptions options) => options.Tenant ?? "none"); + + var option = sut.CreateDocumentationModel().Commands.Single().Options.Single(); + + option.Aliases.Should().Contain("--tenant-name"); + } + + [ReplOptionsGroup] + private sealed class NullHiddenAliasesOptions + { + [ReplOption(Aliases = ["--tenant-name"], HiddenAliases = null!)] + public string? Tenant { get; set; } + } + [TestMethod] [Description("Fluent alias visibility follows a route option's case-insensitive override: parser-equivalent aliases are all hidden from help and documentation but remain accepted by parsing under a case-sensitive global default.")] public void When_FluentAliasesUseCaseInsensitiveOverride_Then_AllEquivalentSpellingsAreHiddenFromDiscovery() From 6bbd293404b16aa5dcc1d6203a1b4983e68862ff Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 25 Jul 2026 23:45:57 -0400 Subject: [PATCH 61/61] fix: keep a global's canonical token visible across case-mode changes IsGlobalOptionAliasHidden checked membership in HiddenAliases using the currently active comparer, with no distinction between the canonical token and an alias. A global registered under case-sensitive parsing with canonical --tenant and a case-distinct hidden alias --TENANT (a legal registration at that point) had its canonical token incorrectly classified as hidden the moment parsing switched to case-insensitive, because "--tenant" then compares equal to the already-hidden "--TENANT" under the new comparer. GlobalOptionBuilder.HiddenAlias's own contract promises the canonical token stays visible; only Hidden() on the whole definition may retract it. The canonical-identity check must use Ordinal specifically: using the effective (now case-insensitive) comparer for it would also exempt the "--TENANT" alias string itself from its own HiddenAliases membership, undoing the fix for the entry the caller actually asked to hide. --- src/Repl.Core/ParsingOptions.cs | 14 ++++++++++++- .../Given_HelpDiscovery.cs | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index a0da40df..c2f610b5 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -257,8 +257,20 @@ internal void SetGlobalOptionAliasHidden(string canonicalName, string alias, boo _customTokenOwnershipCache = null; } + // The canonical token's visibility is governed solely by the definition's own IsHidden, never + // by HiddenAliases: a case-distinct hidden alias (registered while case-sensitive) can become + // equivalent to the canonical token once parsing switches to case-insensitive, and without this + // guard that would incorrectly hide the canonical spelling too. + // + // The canonical check is deliberately Ordinal, not the effective comparer: the effective + // comparer is exactly what makes "--TENANT" equivalent to canonical "--tenant" once parsing + // turns case-insensitive, and using it here would ALSO exempt that distinct alias string from + // its own HiddenAliases membership — the opposite of what HiddenAlias("--TENANT") asked for. + // Ordinal identifies only the canonical spelling itself; every other alias string still falls + // through to the normal effective-comparer membership check below. internal bool IsGlobalOptionAliasHidden(GlobalOptionDefinition definition, string token) => - definition.HiddenAliases.Contains(token, ResolveOptionTokenComparer()); + !string.Equals(token, definition.CanonicalToken, StringComparison.Ordinal) + && definition.HiddenAliases.Contains(token, ResolveOptionTokenComparer()); private StringComparer ResolveOptionTokenComparer() => OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index aa214866..441764b2 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -279,6 +279,26 @@ public void When_GlobalAliasIsUnhiddenThroughEquivalentCasing_Then_OneRepresenta upper.Text.Should().Contain("north"); } + [TestMethod] + [Description("A global registered under case-sensitive parsing with canonical --tenant and a case-distinct hidden alias --TENANT must keep its canonical token visible after switching to case-insensitive parsing: the canonical token's visibility is governed by the definition's own IsHidden, never by HiddenAliases becoming case-equivalent to it.")] + public void When_CaseDistinctHiddenAliasBecomesEquivalentToCanonicalAfterModeChange_Then_CanonicalStaysVisible() + { + var sut = ReplApp.Create(); + sut.Options(options => + options.Parsing.AddGlobalOption("tenant", aliases: ["--TENANT"])); + sut.Options(options => options.Parsing.GlobalOption("tenant").HiddenAlias("--TENANT")); + sut.Options(options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + sut.Map("show", static string (IGlobalOptionsAccessor globals) => globals.GetValue("tenant") ?? "none"); + + var help = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + var canonical = ConsoleCaptureHelper.Capture(() => sut.Run(["--tenant", "acme", "show", "--no-logo"])); + + help.Text.Should().Contain("--tenant"); + help.Text.Should().NotContain("--TENANT"); + canonical.ExitCode.Should().Be(0, canonical.Text); + canonical.Text.Should().Contain("acme"); + } + [TestMethod] [Description("A hidden command option stays bindable when explicitly provided but is omitted from command help.")] public void When_CommandOptionIsHidden_Then_HelpOmitsItAndExplicitInvocationStillBinds()