Skip to content

Commit db59175

Browse files
EvangelinkCopilotCopilot
authored
Auto-promote scalar JSON values for arg-bearing CLI options (fixes #8830) (#8836)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d29f6a1 commit db59175

4 files changed

Lines changed: 349 additions & 3 deletions

File tree

src/Platform/Microsoft.Testing.Platform/Configurations/AggregatedConfiguration.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using Microsoft.Testing.Platform.CommandLine;
5+
using Microsoft.Testing.Platform.Extensions.CommandLine;
56
using Microsoft.Testing.Platform.Helpers;
67
using Microsoft.Testing.Platform.Logging;
78
using Microsoft.Testing.Platform.Services;
@@ -170,6 +171,28 @@ internal IReadOnlyList<JsonCommandLineOptionEntry> EnumerateJsonCommandLineOptio
170171
return jsonProvider?.EnumerateCommandLineOptions() ?? [];
171172
}
172173

174+
/// <summary>
175+
/// Normalizes JSON-sourced scalar command-line option entries to the indexed shape for any
176+
/// option in <paramref name="optionByName"/> with <see cref="ArgumentArity.Min"/> &gt;= 1,
177+
/// so that subsequent <see cref="TryGetCommandLineOptionFromProviders"/> and validator passes
178+
/// see a uniform representation. See
179+
/// <see cref="JsonConfigurationSource.JsonConfigurationProvider.NormalizeCommandLineOptionScalars"/>
180+
/// for the full rationale.
181+
/// </summary>
182+
/// <remarks>
183+
/// A no-op when no JSON configuration source is registered. Safe to call at most once after
184+
/// the option registry is fully assembled and before any consumer reads command-line option
185+
/// values from <see cref="IConfiguration"/>.
186+
/// </remarks>
187+
internal void NormalizeJsonCommandLineOptionScalars(IReadOnlyDictionary<string, CommandLineOption> optionByName)
188+
{
189+
JsonConfigurationSource.JsonConfigurationProvider? jsonProvider = _configurationProviders
190+
.OfType<JsonConfigurationSource.JsonConfigurationProvider>()
191+
.FirstOrDefault();
192+
193+
jsonProvider?.NormalizeCommandLineOptionScalars(optionByName);
194+
}
195+
173196
public async Task CheckTestResultsDirectoryOverrideAndCreateItAsync(IFileLoggerProvider? fileLoggerProvider)
174197
{
175198
_resultsDirectory = _fileSystem.CreateDirectory(this[PlatformConfigurationConstants.PlatformResultDirectory]!);

src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using Microsoft.Testing.Platform.CommandLine;
5+
using Microsoft.Testing.Platform.Extensions.CommandLine;
56
using Microsoft.Testing.Platform.Helpers;
67
using Microsoft.Testing.Platform.Logging;
78
using Microsoft.Testing.Platform.Resources;
@@ -410,6 +411,108 @@ internal IReadOnlyList<JsonCommandLineOptionEntry> EnumerateCommandLineOptions()
410411
return result;
411412
}
412413

414+
/// <summary>
415+
/// Rewrites scalar <c>commandLineOptions:&lt;name&gt;</c> entries to the indexed shape
416+
/// (<c>commandLineOptions:&lt;name&gt;:0</c>) for options registered with
417+
/// <see cref="ArgumentArity.Min"/> &gt;= 1, using <paramref name="optionByName"/> as the
418+
/// option registry.
419+
/// </summary>
420+
/// <remarks>
421+
/// <para>
422+
/// The CLI-backed configuration provider stores zero-arity flags at the bare option key and
423+
/// arg-bearing options under indexed keys (one per argument). The JSON provider cannot
424+
/// distinguish these shapes at parse time because the user freely writes either
425+
/// <c>"foo": "value"</c> (scalar) or <c>"foo": ["value"]</c> (array). For arg-bearing
426+
/// options that always require at least one argument, a scalar value is always the first
427+
/// argument — never a presence marker. Storing it under the indexed key normalizes the
428+
/// shape so both <see cref="EnumerateCommandLineOptions"/> and
429+
/// <see cref="AggregatedConfiguration.TryGetCommandLineOptionFromProviders"/> see one
430+
/// consistent representation.
431+
/// </para>
432+
/// <para>
433+
/// In particular, this fixes the JSON scalar/bool ambiguity from #6349/#8830: a value like
434+
/// <c>"my-option": "true"</c> for an arg-bearing option used to be misinterpreted as a
435+
/// presence marker (zero arguments) instead of being passed as the first argument value.
436+
/// </para>
437+
/// <para>
438+
/// Optional-arg options (<c>Min == 0 &amp;&amp; Max &gt;= 1</c>) are left untouched because
439+
/// either interpretation (presence vs. scalar argument) is semantically valid; users who
440+
/// need to disambiguate should use the explicit array form.
441+
/// </para>
442+
/// </remarks>
443+
internal void NormalizeCommandLineOptionScalars(IReadOnlyDictionary<string, CommandLineOption> optionByName)
444+
{
445+
if (_singleValueData is null || _singleValueData.Count == 0)
446+
{
447+
return;
448+
}
449+
450+
const string sectionName = PlatformConfigurationConstants.CommandLineOptionsSectionName;
451+
string sectionPrefix = sectionName + PlatformConfigurationConstants.KeyDelimiter;
452+
453+
List<(string OldKey, string NewKey, string Value)>? rewrites = null;
454+
455+
foreach (KeyValuePair<string, string?> kvp in _singleValueData)
456+
{
457+
if (!kvp.Key.StartsWith(sectionPrefix, StringComparison.OrdinalIgnoreCase))
458+
{
459+
continue;
460+
}
461+
462+
// Bare key only: "commandLineOptions:<name>" with no further colon. Indexed entries
463+
// already use the canonical shape and are not subject to the scalar/bool ambiguity.
464+
string remainder = kvp.Key.Substring(sectionPrefix.Length);
465+
if (remainder.Length == 0
466+
|| remainder.IndexOf(PlatformConfigurationConstants.KeyDelimiter, StringComparison.Ordinal) >= 0)
467+
{
468+
continue;
469+
}
470+
471+
// A null value at the bare key represents an empty object/array; the schema
472+
// validator in EnumerateCommandLineOptions will reject it later. Skip here so we
473+
// never promote a placeholder to an indexed slot.
474+
if (kvp.Value is null)
475+
{
476+
continue;
477+
}
478+
479+
if (!optionByName.TryGetValue(remainder, out CommandLineOption? option))
480+
{
481+
// Unknown option name. Leave alone so the unknown-option validator pass can
482+
// surface a clear error referencing testconfig.json.
483+
continue;
484+
}
485+
486+
if (option.Arity.Min < 1)
487+
{
488+
continue;
489+
}
490+
491+
string newKey = kvp.Key + PlatformConfigurationConstants.KeyDelimiter + "0";
492+
493+
// Defensive: if the indexed slot already exists, the JSON contained both a scalar
494+
// and an array entry for the same option, which the schema validator will flag as
495+
// malformed. Don't clobber.
496+
if (_singleValueData.ContainsKey(newKey))
497+
{
498+
continue;
499+
}
500+
501+
(rewrites ??= []).Add((kvp.Key, newKey, kvp.Value));
502+
}
503+
504+
if (rewrites is null)
505+
{
506+
return;
507+
}
508+
509+
foreach ((string oldKey, string newKey, string value) in rewrites)
510+
{
511+
_singleValueData.Remove(oldKey);
512+
_singleValueData[newKey] = value;
513+
}
514+
}
515+
413516
private static bool StartsWithChar(string text, char c)
414517
{
415518
for (int i = 0; i < text.Length; i++)

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Testing.Platform.CommandLine;
66
using Microsoft.Testing.Platform.Configurations;
77
using Microsoft.Testing.Platform.Extensions;
8+
using Microsoft.Testing.Platform.Extensions.CommandLine;
89
using Microsoft.Testing.Platform.Helpers;
910
using Microsoft.Testing.Platform.Logging;
1011
using Microsoft.Testing.Platform.OutputDevice;
@@ -207,6 +208,29 @@ private async Task<BuildContext> SetupCommonServicesAsync(
207208
IReadOnlyList<JsonCommandLineOptionEntry> jsonCommandLineOptions;
208209
try
209210
{
211+
// Normalize JSON-sourced scalar option entries to the indexed shape for arg-bearing
212+
// options before any consumer reads them. This makes "foo": "value" in testconfig.json
213+
// behave identically to "foo": ["value"], removing the foot-gun where a scalar like
214+
// "timeout": "true" was misinterpreted as a presence marker. See #6349/#8830.
215+
//
216+
// Defensive: option providers may register duplicate names; CommandLineOptionsValidator
217+
// catches that later (ValidateOptionsAreNotDuplicated) and reports a clear error. Skip
218+
// duplicates here so the normalization step itself doesn't crash for that malformed setup.
219+
var optionByName = new Dictionary<string, CommandLineOption>(StringComparer.OrdinalIgnoreCase);
220+
foreach (ICommandLineOptionsProvider optionsProvider in context.CommandLineHandler.SystemCommandLineOptionsProviders
221+
.Concat(context.CommandLineHandler.ExtensionsCommandLineOptionsProviders))
222+
{
223+
foreach (CommandLineOption option in optionsProvider.GetCommandLineOptions())
224+
{
225+
if (!optionByName.ContainsKey(option.Name))
226+
{
227+
optionByName.Add(option.Name, option);
228+
}
229+
}
230+
}
231+
232+
context.Configuration.NormalizeJsonCommandLineOptionScalars(optionByName);
233+
210234
jsonCommandLineOptions = context.Configuration.EnumerateJsonCommandLineOptions();
211235
}
212236
catch (FormatException ex) when (!loggingState.CommandLineParseResult.HasTool)

0 commit comments

Comments
 (0)