Skip to content

Refuse Void anywhere but a return type or a Result's value [minor] - #198

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/fix-void-nested-validation
Sep 15, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/fix-void-nested-validation

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #172

What was wrong

ValidateParameter refused a top-level Void parameter, and nothing else refused Void at all. A wrapper is transparent to everything else in validation — a class named inside an Optional is checked exactly as one named directly is — and that transparency is what let it through. Member-level checks tested only for None.

So all four of these validated cleanly:

member type before generated C++
Void clean void value{};
Optional<Void> clean std::optional<void>
Span<Void> clean std::span<void>
Optional<None> clean CppGenerationException at generation time

None of those three C++ spellings is a type. The compiler that refused them was the consumer's, pointing at generated code rather than at the schema that produced it. The editor's type picker offers Void for return types and the same picker serves members, so this is a reachable editing state rather than a contrived one.

CLAUDE.md states the invariant as "no Array, Result or Void parameters, no None anywhere generatable". Neither half held below the top level.

The fix

One shared choke point, as the issue's analysis and triage both proposed. ValidateType now carries a TypePosition down the descent, and a new ValidateTypeStandsHere asks the question once on the way down rather than at each call site.

TypePosition names the only two positions an absent value may stand in:

  • Return — a function's return type, which is what Void is for.
  • ResultValue — the value a Result carries. Result<Void> is the honest spelling of "can fail, produces nothing" and it is a type in both target languages: std::expected<void, E> and the one-argument Result<TError>. The exemption is the Result's own value and does not carry through a wrapper inside it, so Result<Optional<Void>> is still refused.

The other two positions — Declared (a member's or parameter's type as written) and Nested (inside a wrapper, an array or a vector) — refuse Void.

None is reported on the same descent, but only below a declaration. A member, a parameter and a return type each already say what is unfinished in their own words, and a vector says what its components have to be, so each keeps its one message. Below them nobody was saying anything, which is why Optional<None> reached the generator and threw there.

Two carve-outs keep one mistake to one message:

  • A member with no type still produces only its existing warning.
  • A vector or colour whose component is None or Void still gets the message naming what a component has to be, which is more use than being told that Void carries no value.

The existing Void parameter message ("Remove it") is unchanged — it names what to do, which the general message cannot.

Tests

Schema.Test/AbsentTypeValidationTests.cs (19 cases) covers both halves: the shapes now refused, and the six places that must not gain a second message.

Verified the tests catch the bug rather than merely passing. Restoring the pre-fix Schema.Validation.cs and re-running fails exactly the 13 bug-catching cases, while the 6 over-reporting guards pass either way — which is what they are for:

Test run summary: Failed!
  total: 497
  failed: 13
  succeeded: 484

CppTypeMappingTests.VoidIsRefusedByTheSchemaRatherThanByTheMapper covers the third acceptance criterion — that validation is the actual gate, not generation-time luck. It asserts both halves together: the mapper does emit the three ill-formed spellings, and the schema refuses the same three before a generator is reached. That pairing matters for Void specifically, because unlike None the mapper has an answer for it wherever it is asked, so generation cannot be what catches it. This test also fails on the reverted fix.

Verification

  • dotnet build Schema.sln -c Release — 0 warnings, 0 errors
  • Schema.Test — 497/497 passing (478 before this branch)
  • Schema.Cpp.Test — 103/103 passing
  • Schema.Editor.Test — 17/17 passing

The samples/ breadth suite still validates clean: neither sample has a Void outside a return type, which LegacySampleGenerationTests already relied on.

Docs

The CLAUDE.md paragraph stating the invariant now says where it is enforced and names the two exempt positions. CHANGELOG.md / VERSION.md / AUTHORS.md are pipeline-generated and untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JhsxzNdL3VieTYFXFpwmov


Generated by Claude Code

Void was refused as a parameter and nowhere else. A wrapper is transparent to
everything else in validation - a class named inside an Optional is checked
exactly as one named directly is - and that transparency is what let it through:
a member typed Void, or an Optional<Void> or Span<Void> anywhere, validated
cleanly and then emitted `void x{};`, `std::optional<void>` or `std::span<void>`,
none of which are types. The compiler that refused them was the consumer's,
pointing at generated code rather than at the schema that produced it. The
editor's type picker offers Void for return types and the same picker serves
members, so it is a reachable edit rather than a contrived one.

ValidateType now carries a TypePosition down the descent, and ValidateTypeStandsHere
asks the question once on the way down rather than at each call site. The two
positions an absent value may stand in are a function's return type and the value
a Result carries, which is how "can fail, produces nothing" is spelled and is a
type in both target languages.

None is reported on the same descent, but only below a declaration: a member, a
parameter and a return type each already say what is unfinished in their own
words, and a vector says what its components have to be, so those keep their one
message. Below them nobody was saying anything, so an Optional<None> reached the
generator and threw there.

AbsentTypeValidationTests covers both halves - the shapes now refused, and the
six places that must not gain a second message. CppTypeMappingTests pins why
validation has to be the gate for Void in particular: unlike None, the mapper has
an answer for it wherever it is asked, so generation cannot be what catches it.

Fixes #172

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhsxzNdL3VieTYFXFpwmov
Comment thread Schema.Test/AbsentTypeValidationTests.cs Fixed
github-code-quality flagged the loop for mapping its iteration variable
straight to another one. A row per case is what the wrapper cases beside it
already do, and it says which of the two failed rather than stopping at the
first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhsxzNdL3VieTYFXFpwmov
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit b8a52a3 into main Sep 15, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/fix-void-nested-validation branch September 15, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Void as a member or wrapper element passes validation and generates void x{}; / std::optional&lt;void&gt;

2 participants