schemagen: a CamelCase property key collides with the generated type name of its value (generated code does not compile) #68

Open
opened 2026-10-18 10:57:40 +00:00 by weaselbot · 0 comments
Member

Summary

weaseljson_schemagen.py derives a struct member name from a property key with
sanitize() (contrib/schemagen/weaseljson_schemagen.py:111) and a type name for
the property's value type from the same key with camel() via
unique_name() (:153, :251). These two names are allocated from two
completely separate namespaces (seen_cpp per struct at :451-460 for members,
self._used_names at :264/:587 for types), so nothing prevents them from
being identical.

Whenever a property key is "already CamelCase" (it starts with an uppercase letter
and contains only [A-Za-z0-9_], so sanitize(key) == camel(key)) and the
property's value produces a named type (an inline object, a $ref to an object,
or an enum), the generated struct contains a member with the same name as the
type it uses:

struct Root1 {
  std::optional<Address> Address;  // "Address"
};

That is ill-formed C++ (the member declaration changes the meaning of the type
name in class scope). schemagen still exits 0 with no diagnostic, so the breakage
is only discovered when the generated header is compiled. With a second member
that uses the same type, Address then resolves to the member, which breaks
both GCC and Clang.

Capitalized property names ("Address", "User", "Id", "Name", ...) are
extremely common in real JSON, so this makes the generator unusable for those
schemas.

Root cause

  • Builder._build_object() (:412) names the struct via
    self.unique_name(defname or hint) (:413) and gives each field
    cpp = sanitize(key) (:454); the dedupe loop at :457-460 only compares
    member names against other member names of the same struct.
  • Emitter._struct_decls() (:688) blindly emits f' {store} {f.cpp}...' (:705)
    and never checks the member name against the generated type names, which live in
    self.b._used_names / _is_reserved() and are only consulted by unique_name().
  • Since sanitize("Address") == "Address" and camel("Address") == "Address", the
    collision is guaranteed for capitalized keys.

Reproduction 1 — inline object property (GCC)

schema.json:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "Address": {
      "type": "object",
      "additionalProperties": false,
      "properties": { "city": { "type": "string" } }
    }
  }
}
python3 contrib/schemagen/weaseljson_schemagen.py schema.json -o gen.h --namespace demo
echo $?          # 0, no diagnostic
printf '#include "gen.h"\nint main(){ demo::RootBuilder b; }\n' > use.cpp
g++ -std=c++20 -I include -c use.cpp -o /dev/null

Generated (gen.h):

struct Address {
  std::optional<std::string> city;  // "city"
};

struct Root1 {
  std::optional<Address> Address;  // "Address"
};

using Root = Root1;

Actual (g++ 16.1.1):

gen.h:25:26: error: declaration of 'std::optional<demo::Address> demo::Root1::Address' changes meaning of 'Address' [-Wchanges-meaning]
   25 |   std::optional<Address> Address;  // "Address"
      |                          ^~~~~~~
gen.h:25:17: note: used here to mean 'struct demo::Address'
gen.h:20:8: note: declared here
   20 | struct Address {

Expected: the header compiles (nothing in this schema is outside the documented
supported subset).

The same happens with an enum-valued capitalized property:

{"type":"object","additionalProperties":false,"properties":{"Color":{"enum":["red","green"]}}}
enum class Color : int { red, green };
...
gen.h:22:24: error: declaration of 'std::optional<demo::Color> demo::Root1::Color' changes meaning of 'Color' [-Wchanges-meaning]

Renaming the property to lowercase ("color") makes it compile, which shows the
trigger is purely the sanitize()/camel() name collision.

Reproduction 2 — $ref'd type used by two properties (GCC and Clang)

schema.json:

{
  "$defs": {
    "Address": {
      "type": "object",
      "additionalProperties": false,
      "properties": { "city": { "type": "string" } }
    }
  },
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "Address": { "$ref": "#/$defs/Address" },
    "home":    { "$ref": "#/$defs/Address" }
  }
}

Generated:

struct Root1 {
  std::optional<Address> Address;  // "Address"
  std::optional<Address> home;     // "home"
};

Here the second member uses the shadowed type name, so both compilers reject it:

$ g++ -std=c++20 -I include -c use.cpp -o /dev/null
gen.h:25:26: error: declaration of 'std::optional<demo::Address> demo::Root1::Address' changes meaning of 'Address' [-Wchanges-meaning]
gen.h:26:17: error: invalid use of non-static data member 'demo::Root1::Address'
gen.h:410:25: error: 'struct demo::Root1' has no member named 'home'

$ clang++ -std=c++20 -I include -c use.cpp -o /dev/null
gen.h:26:17: error: invalid use of non-static data member 'Address'
   26 |   std::optional<Address> home;  // "home"
1 error generated.

Suggested fix

Allocate member names from the same registry as type names (e.g. check
sanitize(key) against self.b._used_names/_name_taken() in
_build_object() and suffix the member — or the type — until it no longer
collides), so a member never shadows a namespace-scope type it or a sibling
member uses.

Test gap

contrib/schemagen/test_schemagen.py has a generate_and_compile() helper, but
every schema it compiles uses lowercase property keys, so no existing test covers
a capitalized property key whose value has a generated type name.

Related, but distinct

  • #21 (closed) reserved the generated names Root/Skip/ArrN/RootScalar +
    the Kind enum.
  • #59 / #60 (open) are about type names that are not valid identifiers
    (digit-leading, ./+//), #62 (open) about member names that are keywords.
  • None of them covers a member name that is a valid identifier colliding with a
    generated type name.
## Summary `weaseljson_schemagen.py` derives a struct **member name** from a property key with `sanitize()` (`contrib/schemagen/weaseljson_schemagen.py:111`) and a **type name** for the property's value type from the *same* key with `camel()` via `unique_name()` (`:153`, `:251`). These two names are allocated from two completely separate namespaces (`seen_cpp` per struct at `:451-460` for members, `self._used_names` at `:264`/`:587` for types), so nothing prevents them from being **identical**. Whenever a property key is "already CamelCase" (it starts with an uppercase letter and contains only `[A-Za-z0-9_]`, so `sanitize(key) == camel(key)`) **and** the property's value produces a named type (an inline object, a `$ref` to an object, or an `enum`), the generated struct contains a member with the same name as the type it uses: ```cpp struct Root1 { std::optional<Address> Address; // "Address" }; ``` That is ill-formed C++ (the member declaration changes the meaning of the type name in class scope). schemagen still exits 0 with no diagnostic, so the breakage is only discovered when the generated header is compiled. With a second member that uses the same type, `Address` then resolves to the *member*, which breaks both GCC and Clang. Capitalized property names (`"Address"`, `"User"`, `"Id"`, `"Name"`, ...) are extremely common in real JSON, so this makes the generator unusable for those schemas. ## Root cause - `Builder._build_object()` (`:412`) names the struct via `self.unique_name(defname or hint)` (`:413`) and gives each field `cpp = sanitize(key)` (`:454`); the dedupe loop at `:457-460` only compares member names against *other member names* of the same struct. - `Emitter._struct_decls()` (`:688`) blindly emits `f' {store} {f.cpp}...'` (`:705`) and never checks the member name against the generated type names, which live in `self.b._used_names` / `_is_reserved()` and are only consulted by `unique_name()`. - Since `sanitize("Address") == "Address"` and `camel("Address") == "Address"`, the collision is guaranteed for capitalized keys. ## Reproduction 1 — inline object property (GCC) `schema.json`: ```json { "type": "object", "additionalProperties": false, "properties": { "Address": { "type": "object", "additionalProperties": false, "properties": { "city": { "type": "string" } } } } } ``` ```sh python3 contrib/schemagen/weaseljson_schemagen.py schema.json -o gen.h --namespace demo echo $? # 0, no diagnostic printf '#include "gen.h"\nint main(){ demo::RootBuilder b; }\n' > use.cpp g++ -std=c++20 -I include -c use.cpp -o /dev/null ``` Generated (`gen.h`): ```cpp struct Address { std::optional<std::string> city; // "city" }; struct Root1 { std::optional<Address> Address; // "Address" }; using Root = Root1; ``` Actual (g++ 16.1.1): ``` gen.h:25:26: error: declaration of 'std::optional<demo::Address> demo::Root1::Address' changes meaning of 'Address' [-Wchanges-meaning] 25 | std::optional<Address> Address; // "Address" | ^~~~~~~ gen.h:25:17: note: used here to mean 'struct demo::Address' gen.h:20:8: note: declared here 20 | struct Address { ``` Expected: the header compiles (nothing in this schema is outside the documented supported subset). The same happens with an enum-valued capitalized property: ```json {"type":"object","additionalProperties":false,"properties":{"Color":{"enum":["red","green"]}}} ``` ``` enum class Color : int { red, green }; ... gen.h:22:24: error: declaration of 'std::optional<demo::Color> demo::Root1::Color' changes meaning of 'Color' [-Wchanges-meaning] ``` Renaming the property to lowercase (`"color"`) makes it compile, which shows the trigger is purely the `sanitize()`/`camel()` name collision. ## Reproduction 2 — `$ref`'d type used by two properties (GCC **and** Clang) `schema.json`: ```json { "$defs": { "Address": { "type": "object", "additionalProperties": false, "properties": { "city": { "type": "string" } } } }, "type": "object", "additionalProperties": false, "properties": { "Address": { "$ref": "#/$defs/Address" }, "home": { "$ref": "#/$defs/Address" } } } ``` Generated: ```cpp struct Root1 { std::optional<Address> Address; // "Address" std::optional<Address> home; // "home" }; ``` Here the *second* member uses the shadowed type name, so both compilers reject it: ``` $ g++ -std=c++20 -I include -c use.cpp -o /dev/null gen.h:25:26: error: declaration of 'std::optional<demo::Address> demo::Root1::Address' changes meaning of 'Address' [-Wchanges-meaning] gen.h:26:17: error: invalid use of non-static data member 'demo::Root1::Address' gen.h:410:25: error: 'struct demo::Root1' has no member named 'home' $ clang++ -std=c++20 -I include -c use.cpp -o /dev/null gen.h:26:17: error: invalid use of non-static data member 'Address' 26 | std::optional<Address> home; // "home" 1 error generated. ``` ## Suggested fix Allocate member names from the same registry as type names (e.g. check `sanitize(key)` against `self.b._used_names`/`_name_taken()` in `_build_object()` and suffix the *member* — or the *type* — until it no longer collides), so a member never shadows a namespace-scope type it or a sibling member uses. ## Test gap `contrib/schemagen/test_schemagen.py` has a `generate_and_compile()` helper, but every schema it compiles uses lowercase property keys, so no existing test covers a capitalized property key whose value has a generated type name. ## Related, but distinct - #21 (closed) reserved the *generated* names `Root`/`Skip`/`ArrN`/`RootScalar` + the `Kind` enum. - #59 / #60 (open) are about type names that are not valid identifiers (digit-leading, `.`/`+`/`/`), #62 (open) about member names that are keywords. - None of them covers a member name that is a *valid identifier* colliding with a generated type name.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/weaseljson#68