Render non-null CLI strings literally - #3861
Conversation
There was a problem hiding this comment.
Code review
Reviewed the diff, the linked issue (#3780), and traced the full value-flow through CommandArgumentBuilder (including the legacy vs. new CliOptionValue paths for optional-arity options).
Summary: This is a clean, well-scoped fix. It does exactly what #3780 asked for — one consistent rule ("non-null renders literally, only null is rejected/omitted") applied uniformly across required options, grouped options, value pairs, and positionals, replacing the previous inconsistent IsNullOrWhiteSpace checks scattered across AddOption, AddGroupedOption, AddOptionValuePairs, and the CliOptionValue implicit conversion. Removing IsEmpty/the whitespace throw in favor of trusting the null check is also a nice simplification — less defensive code guarding against a case that's no longer a case.
Things I specifically checked and are handled correctly:
AddArgumentsnow only rejects aRequiredpositional whenvalues.Count == 0(i.e. the backing value wasnull), not when it renders to""/" "— matches the new doc comment onCliArgumentAttribute.Required.AddGroupedOption/AddOptionValuePairscorrectly distinguish "null operand → throwCreateNullRequiredValueException" from "empty/whitespace operand → render literally," and the grouped-option null check is still reachable via theCliValuePair-sourced path even though plain string collections never surface nulls (those get filtered earlier inGetCollectionValues).- The legacy-generated-option compatibility shim (
ToLegacyOptionalValue, gated onGeneratedCodeAttribute.Tool == "ModularPipelines.OptionsGenerator") deliberately keeps mapping empty/whitespace strings toCliOptionValue.Barefor old generated packages, while the newCliOptionValue-typed path renders them literally. That split is intentional and is exercised byParser_Preserves_Legacy_Multiple_Optional_String_Valuesvs.Parser_Groups_Literal_Optional_Values, so it's not a regression — just flagging it because the new XML doc onCliOptionAttribute.ValueArity("Non-null string values are rendered literally...") doesn't call out that this doesn't apply to the legacy shim. Worth a one-line doc note if a future reader hits it, but not blocking. - Test coverage is thorough: empty, whitespace,
null, grouped, pairs, and the legacy-compat path are all covered, including the negative cases (Parser_Rejects_Null_Value_Pair_Operands).
No bugs or CLAUDE.md violations found. No architectural concerns beyond the minor doc-completeness note above — the change actually reduces an anti-pattern (ad hoc validation duplicated across four call sites) rather than introducing one.
|
CI diagnosis: Ubuntu, Windows, and macOS all fail only on the current-main |
|
The shared RunReportTests regression is fixed on current main by merged PR #3844. Refreshed this branch onto current main for clean CI and review. |
b639004 to
4432e13
Compare
|
@codex review |
|
@claude review |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
Code review
Re-reviewed the diff plus the linked issue (#3780) and traced the value-flow through CommandArgumentBuilder. A prior review from claude[bot] already covered the core design (one consistent "null is the only absent value" rule applied uniformly across options/grouped options/pairs/positionals) and flagged the legacy-shim doc gap — that analysis holds up and I won't repeat it. One additional, previously-unflagged issue and one architectural suggestion:
Misleading exception message for empty (non-null) required argument collections
src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs:162-167
if (argumentPart.Attribute.Required && values.Count == 0)
{
throw new ArgumentException(
$"Required CLI argument '{optionsType.Name}.{argumentPart.PropertyName}' cannot be null.",
argumentPart.PropertyName);
}Before this PR the message was "cannot be null or empty", which was accurate for both causes of values.Count == 0. Now it unconditionally says "cannot be null", but that branch is also reached when the backing property is a non-null, empty collection — confirmed by the existing test Parser_Rejects_Empty_Required_CliArgument_Collection (TestCliOptionsWithRequiredArgumentCollection { Files = [] }, line ~690), which still passes because it only asserts the message contains the property name, not the specific wording.
For a Required scalar argument, values.Count == 0 really does mean the value was null, so the message is fine there. But for a Required collection argument (IEnumerable<string>), Files = [] is a perfectly non-null value that still hits this branch — telling the caller "cannot be null" when they passed a non-null empty array will send them looking in the wrong place. Suggest restoring the null-or-empty framing for this specific message (e.g. "cannot be null or empty"), since the single check now covers two distinct causes with one message that's only accurate for one of them.
Suggestion: validate CliValuePair nullness at construction, not at render time
src/ModularPipelines/Models/CliValuePair.cs:10 / CommandArgumentBuilder.cs:544-547
public record CliValuePair(string First, string Second);
...
if (pair.First is null || pair.Second is null)
{
throw CreateNullRequiredValueException(optionsType, optionPart);
}First/Second are declared as non-nullable string, but the only way the runtime null-check ever fires is because callers can still pass null! (as the new Parser_Rejects_Null_Value_Pair_Operands test does) or construct one from a nullable-oblivious context. Right now that contract violation isn't caught until the object is deep inside CommandArgumentBuilder's rendering pipeline — possibly far away in both code and time from where the bad CliValuePair was actually built (e.g. constructed in a module, stored, and only rendered when the pipeline later executes that command). Adding ArgumentNullException.ThrowIfNull(First)/(Second) in the record's primary constructor would fail fast at the actual mistake site, produce a more specific exception (ArgumentNullException vs. a generic InvalidOperationException keyed off optionPart), and let the non-nullable annotation on First/Second actually be a real, enforced guarantee rather than just a compile-time hint — which in turn would let AddGroupedOption/AddOptionValuePairs drop their defensive null checks entirely, since a validly-constructed CliValuePair could never contain one. This is a small change but it fits the PR's own goal of moving validation to one clear, consistent place instead of scattering it through the builder.
Neither point blocks this PR — the collection-message issue is a wording regression worth a quick follow-up, and the CliValuePair point is a structural nice-to-have for a future pass.
|
Refreshed onto current main after Ubuntu's core test host ran 27m18s and exited without a failing-test summary; all other jobs passed. Validation on 55353a7: CliAttributeTests 68/68 and ModularPipelines.slnx Release build 0 warnings/errors. A detached worktree preserved the existing divergent local branch untouched. |
Code ReviewI reviewed the diff, rebuilt the core solution ( SummaryGood, well-scoped fix: it makes Findings1. The record is declared Making these 2.
Both call sites of 3. Minor: Required-argument exception message narrower than its trigger condition (
Nice-to-haveNone of the above block merging — findings 1 and 2 are about keeping the type system honest with the implementation rather than functional bugs, and 3 is a message-wording nit. Everything I could exercise locally (core build, focused test run) passed cleanly. |
Summary
Validation
Closes #3780