weaseljson_schemagen.py mishandles a schema whose root is a nullable object reached via $ref (i.e. the root $ref points at a $defs entry with "type": ["object", "null"]) when that same $defs entry is referenced by another field -- directly (self-reference) or through another object.
The nullable-root-object handling in Emitter.emit() renames the struct to avoid the using Root = std::optional<Root>; self-conflict, but it only updates self.b.objects and reassigns self.root_ty. It does not update the TObj.name of field types that still point at the original object, so those references become stale. Depending on how the stale reference is reached this either:
crashes schemagen with KeyError in break_cycles, or
silently emits C++ that does not compile ('<Name>' was not declared in this scope).
This uses only supported schema features ($ref, type: ["object","null"], object self/cross references, additionalProperties: false).
# A nullable root object would otherwise produce# using Root = std::optional<Root>;# which conflicts with the struct named Root. Rename the inner struct.ifisinstance(self.root_ty,TObj)andself.root_nullable:old_name=self.root_ty.namenew_name=self.b.unique_name("RootInner")obj=self.b.objects.pop(old_name)obj.name=new_nameself.b.objects[new_name]=objself.root_ty=TObj(new_name)
When the root is reached via $ref, root_ty is the same TObj instance that other fields hold (returned from build_def, which caches (TObj, nullable) in self._building). The rename creates a newTObj(new_name) for root_ty and renames the ObjectType, but every other field that $refs this definition still holds the original TObj instance whose .name is the old name (e.g. "Node"). Those TObj.name values are then never updated.
Traceback (most recent call last):
...
File "contrib/schemagen/weaseljson_schemagen.py", line 616, in emit
break_cycles(self.b.objects)
File "contrib/schemagen/weaseljson_schemagen.py", line 493, in break_cycles
if color.get(name, 0) == 0 and dfs(name):
File "contrib/schemagen/weaseljson_schemagen.py", line 483, in dfs
if c == 0 and dfs(t):
File "contrib/schemagen/weaseljson_schemagen.py", line 475, in dfs
for f in objects[name].fields:
~~~~~~~^^^^^^
KeyError: 'Node'
break_cycles recurses into f.ty.name (the stale "Node"), but the object was renamed to RootInner in self.b.objects, so objects["Node"] raises KeyError. A cross-reference cycle (Node <-> Other) crashes the same way.
Reproduction 2 -- schemagen emits non-compiling code (cycle via arrays)
When the stale reference is reached through an array field, needs_complete() is false so break_cycles skips it and does not crash; instead schemagen exits 0 and emits a header that does not compile.
gen.h:21:43: error: 'Node' was not declared in this scope
21 | std::optional<std::vector<std::optional<Node>>> nodes; // "nodes"
| ^~~~
What is NOT affected
A non-nullable root reached via $ref with the same self-reference generates and compiles fine (no rename).
An inline nullable root object (not via $ref) that references a separate$defs entry works (the existing regression test test_self_referential_nullable_object_accepts_nested_null covers only this inline-root case, so the $ref-root case is untested).
Impact
A valid schema using only supported features cannot be processed: schemagen either aborts with a Python KeyError traceback, or silently produces a header that fails to compile. The rename in emit() needs to also rewrite TObj.name for every field type that still points at the renamed object (or, equivalently, rename the shared TObj instance in place instead of creating a new one and leaving the old one referenced).
## Summary
`weaseljson_schemagen.py` mishandles a schema whose root is a **nullable object reached via `$ref`** (i.e. the root `$ref` points at a `$defs` entry with `"type": ["object", "null"]`) when that same `$defs` entry is referenced by another field -- directly (self-reference) or through another object.
The nullable-root-object handling in `Emitter.emit()` renames the struct to avoid the `using Root = std::optional<Root>;` self-conflict, but it only updates `self.b.objects` and reassigns `self.root_ty`. It does **not** update the `TObj.name` of field types that still point at the original object, so those references become stale. Depending on how the stale reference is reached this either:
1. **crashes** schemagen with `KeyError` in `break_cycles`, or
2. **silently emits C++ that does not compile** (`'<Name>' was not declared in this scope`).
This uses only supported schema features (`$ref`, `type: ["object","null"]`, object self/cross references, `additionalProperties: false`).
## Root cause
`contrib/schemagen/weaseljson_schemagen.py`, `Emitter.emit()` (lines 608-614):
```python
# A nullable root object would otherwise produce
# using Root = std::optional<Root>;
# which conflicts with the struct named Root. Rename the inner struct.
if isinstance(self.root_ty, TObj) and self.root_nullable:
old_name = self.root_ty.name
new_name = self.b.unique_name("RootInner")
obj = self.b.objects.pop(old_name)
obj.name = new_name
self.b.objects[new_name] = obj
self.root_ty = TObj(new_name)
```
When the root is reached via `$ref`, `root_ty` is the same `TObj` instance that other fields hold (returned from `build_def`, which caches `(TObj, nullable)` in `self._building`). The rename creates a **new** `TObj(new_name)` for `root_ty` and renames the `ObjectType`, but every other field that `$ref`s this definition still holds the original `TObj` instance whose `.name` is the old name (e.g. `"Node"`). Those `TObj.name` values are then never updated.
## Reproduction 1 -- schemagen crashes (self-referential)
`schema.json`:
```json
{
"$ref": "#/$defs/Node",
"$defs": {
"Node": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": { "next": { "$ref": "#/$defs/Node" } }
}
}
}
```
```sh
python3 contrib/schemagen/weaseljson_schemagen.py schema.json
```
Result (exits non-zero with a traceback):
```
Traceback (most recent call last):
...
File "contrib/schemagen/weaseljson_schemagen.py", line 616, in emit
break_cycles(self.b.objects)
File "contrib/schemagen/weaseljson_schemagen.py", line 493, in break_cycles
if color.get(name, 0) == 0 and dfs(name):
File "contrib/schemagen/weaseljson_schemagen.py", line 483, in dfs
if c == 0 and dfs(t):
File "contrib/schemagen/weaseljson_schemagen.py", line 475, in dfs
for f in objects[name].fields:
~~~~~~~^^^^^^
KeyError: 'Node'
```
`break_cycles` recurses into `f.ty.name` (the stale `"Node"`), but the object was renamed to `RootInner` in `self.b.objects`, so `objects["Node"]` raises `KeyError`. A cross-reference cycle (`Node` <-> `Other`) crashes the same way.
## Reproduction 2 -- schemagen emits non-compiling code (cycle via arrays)
When the stale reference is reached through an array field, `needs_complete()` is `false` so `break_cycles` skips it and does not crash; instead schemagen exits 0 and emits a header that does not compile.
`schema.json`:
```json
{
"$ref": "#/$defs/Node",
"$defs": {
"Node": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": { "items": { "type": "array", "items": { "$ref": "#/$defs/Other" } } }
},
"Other": {
"type": "object",
"additionalProperties": false,
"properties": { "nodes": { "type": "array", "items": { "$ref": "#/$defs/Node" } } }
}
}
}
```
```sh
python3 contrib/schemagen/weaseljson_schemagen.py schema.json -o gen.h --namespace ts
c++ -std=c++20 -fsyntax-only -I include -I . -x c++ - <<'EOF'
#include "gen.h"
int main(){ ts::RootBuilder b; (void)b; }
EOF
```
Generated `gen.h` (the struct is renamed to `RootInner`, but `Other.nodes` still references the old name):
```cpp
struct Other;
struct RootInner;
struct Other {
std::optional<std::vector<std::optional<Node>>> nodes; // "nodes"
...
};
using Root = std::optional<RootInner>;
```
Compile error:
```
gen.h:21:43: error: 'Node' was not declared in this scope
21 | std::optional<std::vector<std::optional<Node>>> nodes; // "nodes"
| ^~~~
```
## What is NOT affected
- A non-nullable root reached via `$ref` with the same self-reference generates and compiles fine (no rename).
- An inline nullable root object (not via `$ref`) that references a *separate* `$defs` entry works (the existing regression test `test_self_referential_nullable_object_accepts_nested_null` covers only this inline-root case, so the `$ref`-root case is untested).
## Impact
A valid schema using only supported features cannot be processed: schemagen either aborts with a Python `KeyError` traceback, or silently produces a header that fails to compile. The rename in `emit()` needs to also rewrite `TObj.name` for every field type that still points at the renamed object (or, equivalently, rename the shared `TObj` instance in place instead of creating a new one and leaving the old one referenced).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
weaseljson_schemagen.pymishandles a schema whose root is a nullable object reached via$ref(i.e. the root$refpoints at a$defsentry with"type": ["object", "null"]) when that same$defsentry is referenced by another field -- directly (self-reference) or through another object.The nullable-root-object handling in
Emitter.emit()renames the struct to avoid theusing Root = std::optional<Root>;self-conflict, but it only updatesself.b.objectsand reassignsself.root_ty. It does not update theTObj.nameof field types that still point at the original object, so those references become stale. Depending on how the stale reference is reached this either:KeyErrorinbreak_cycles, or'<Name>' was not declared in this scope).This uses only supported schema features (
$ref,type: ["object","null"], object self/cross references,additionalProperties: false).Root cause
contrib/schemagen/weaseljson_schemagen.py,Emitter.emit()(lines 608-614):When the root is reached via
$ref,root_tyis the sameTObjinstance that other fields hold (returned frombuild_def, which caches(TObj, nullable)inself._building). The rename creates a newTObj(new_name)forroot_tyand renames theObjectType, but every other field that$refs this definition still holds the originalTObjinstance whose.nameis the old name (e.g."Node"). ThoseTObj.namevalues are then never updated.Reproduction 1 -- schemagen crashes (self-referential)
schema.json:Result (exits non-zero with a traceback):
break_cyclesrecurses intof.ty.name(the stale"Node"), but the object was renamed toRootInnerinself.b.objects, soobjects["Node"]raisesKeyError. A cross-reference cycle (Node<->Other) crashes the same way.Reproduction 2 -- schemagen emits non-compiling code (cycle via arrays)
When the stale reference is reached through an array field,
needs_complete()isfalsesobreak_cyclesskips it and does not crash; instead schemagen exits 0 and emits a header that does not compile.schema.json:Generated
gen.h(the struct is renamed toRootInner, butOther.nodesstill references the old name):Compile error:
What is NOT affected
$refwith the same self-reference generates and compiles fine (no rename).$ref) that references a separate$defsentry works (the existing regression testtest_self_referential_nullable_object_accepts_nested_nullcovers only this inline-root case, so the$ref-root case is untested).Impact
A valid schema using only supported features cannot be processed: schemagen either aborts with a Python
KeyErrortraceback, or silently produces a header that fails to compile. The rename inemit()needs to also rewriteTObj.namefor every field type that still points at the renamed object (or, equivalently, rename the sharedTObjinstance in place instead of creating a new one and leaving the old one referenced).