Skip to content

Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse unreadable or unsizable input - #297

Merged
matt-edmondson merged 7 commits into
mainfrom
claude/project-thread-4f2hax
Oct 20, 2026
Merged

matt-edmondson merged 7 commits into
mainfrom
claude/project-thread-4f2hax

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Matt · project thread

This PR carries seven changes, one commit each.

ImGui 3.75 / UndoRedo 3 (supersedes #277)

Before: the editor stays on ktsu.ImGui.NodeEditor 3.38.5. Dependabot's bump (#277) fails with CS0433. NodeEditor 3.75 brings in ktsu.UndoRedo 3.4, which absorbed ktsu.UndoRedo.Core, so every undo type reached the editor's compilation twice.

After: NodeEditor is at 3.75.3, and the editor references ktsu.UndoRedo 3.4.0 in place of ktsu.UndoRedo.Core 1.1.4.

How: the namespaces and API did not change, so this is a package swap in Directory.Packages.props and the editor's csproj, with no code edits.

A failed save keeps the last save (fixes #292)

Before: SchemaFile.TrySave wrote over the target with File.WriteAllText, which truncates the file before writing. A write that failed partway, such as on a full disk or a dropped network share, left the only saved copy empty or partial, and the editor's memory held the only intact copy.

After: the target holds either the complete previous save or the complete new one, and a failed save leaves nothing behind in the folder.

How: the JSON is written to <name>.tmp-<guid> beside the target. It is then swapped in with File.Replace, or with File.Move when there is no existing file to replace. The temporary file is deleted on any failure. An internal overload takes the write step as a delegate, so three new tests can make it fail partway and check that the original bytes are unchanged and no temporary file remains. The optional SchemaGenerator.GenerateToDisk change is left out, because generated output can be regenerated.

A file the model cannot read is refused, not loaded empty (fixes #278)

Before: a PascalCase or legacy file ({"Classes": [...]}) loaded as an empty schema and reported "Migrated from format version 0". Validation passed, and saving from the editor replaced the file with nothing.

After: SchemaSerializer.Load returns InvalidJson for any root property the model does not read. When the only problem is casing, the message names the camelCase spelling, for example 'Classes' would be 'classes'. A file from a newer format version is still reported as newer, because the version check runs first.

How: the known names come from the serializer's own JsonTypeInfo for Schema, so a property added to the model is accepted as soon as it can be read. The new tests check that every committed legacy sample is refused and every modern sample still loads. docs/schema-format.md documents the refusal.

A null in the C++ options file is refused, not thrown (fixes #291)

Before: kschema --cpp-options threw a NullReferenceException for {"existingTypes": null} or {"existingTypes": {"Kilograms": null}}, and the error did not point at the file.

After: both are refused with a message naming the key. A null for any other value the options need, such as generatedBy or a spelling's include, is also refused.

How: CppGeneratorOptionsFile sets RespectNullableAnnotations, so the deserializer refuses nulls in non-nullable properties. Dictionary values fall outside that setting, so IsSpelled checks for a null entry itself. Six new tests cover these cases.

A discriminator out of position loads, and a missing one is refused (fixes #287)

Before: a type written as { "quantityName": "Mass", "TypeName": "Quantity" }, or a default with DefaultKind after value, threw NotSupportedException out of SchemaSerializer.Load. A type with no recognised discriminator ({} or { "typeName": "Int" }) threw the same exception, so kschema validate crashed instead of reporting the file.

After: the discriminator can appear anywhere in its object. A type without one is reported as InvalidJson, and the CLI says "Could not open".

How: SchemaSerializer.JsonOptions sets AllowOutOfOrderMetadataProperties, and Load also catches NotSupportedException, which is how the serializer reports a polymorphic shape it cannot construct. Four new tests cover these cases.

A null in a hand-written schema no longer throws (fixes #242)

Before: "classes": null, "members": null, a null class name or a null member type loaded "successfully" and then threw a NullReferenceException from Validate(), the editor or a generator. A null list element, such as "classes": [null], did the same.

After: a null property reads exactly as if it were omitted, which is what the serializer already writes, so the default (an empty list, an empty name, None) takes over and validation reports anything that is then incomplete, such as "empty name". A null element in a list is refused as InvalidJson, with its path in the message, for example $.classes[0].members[0]. The CLI says "Could not open".

How: Load parses the document into a JsonNode, removes null properties and stops at the first null array element, then deserializes from the node. Nothing in the model is a dictionary or extension data, so "null means omitted" holds everywhere. Thirteen new tests cover these cases, and docs/schema-format.md documents the rule.

A class that holds itself by value is an error (fixes #293)

Before: a class holding itself, directly (TreeNode.Parent : TreeNode) or through other classes, an Optional or a Result, validated cleanly. It has no finite size, so no generator could produce a type that compiles.

After: validation reports one error per cycle, at the member that closes it, naming the route (A.b → B.c → C.a → A) and suggesting an Array or a Handle, which is how a recursive structure is meant to be written. A reference through an Array, Handle or Interface is not a cycle, and neither is a diamond.

How: a depth-first walk over the classes each member holds by value, which visits each class once, so a cycle is reported once rather than once per class on it. The existing test for two travelsAsBytes classes holding each other now expects this error. Six new tests cover these cases, and disabling the report fails five of them.

The build is clean and all 544 tests in Schema.Test pass on net10.0 locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT

ktsu.ImGui.NodeEditor 3.75 depends on ktsu.UndoRedo 3.4, which absorbed
ktsu.UndoRedo.Core. Referencing both put every undo type in the editor's
compilation twice (CS0433), which is why the NodeEditor bump has been red.
The namespaces and API are unchanged, so swapping the reference is enough.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
…t save

TrySave wrote over the target with File.WriteAllText, which truncates before
writing, so a full disk or a dropped share left the only saved copy empty or
partial. The content now goes to a temporary file beside the target and is
swapped in with File.Replace (or File.Move when there is nothing to replace);
the temporary file is removed on any failure. Fixes #292.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
@matt-edmondson matt-edmondson changed the title Move the editor onto ktsu.UndoRedo 3 so the ImGui 3.75 node editor builds Move the editor onto ktsu.UndoRedo 3, and stop a failed save from truncating the schema Oct 19, 2026
A PascalCase or legacy file loaded as an empty schema and was reported as a
successful migration from format version 0, so validation passed and saving
from the editor replaced the file with nothing. Load now refuses any root
property the serializer's own contract does not name, and says so when the
only problem is casing.

Fixes #278

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
@matt-edmondson matt-edmondson changed the title Move the editor onto ktsu.UndoRedo 3, and stop a failed save from truncating the schema Move the editor onto ktsu.UndoRedo 3, and stop saves and loads from silently losing a schema Oct 19, 2026
{"existingTypes": null}, or a null entry in it, reached IsSpelled and
threw a NullReferenceException with nothing pointing at the file. The
options now respect nullable annotations, so a null where a value is
needed is refused by the deserializer, and a null existing-type entry
is refused by name.

Fixes #291

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
@matt-edmondson matt-edmondson changed the title Move the editor onto ktsu.UndoRedo 3, and stop saves and loads from silently losing a schema Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse nulls in C++ options Oct 19, 2026
A "TypeName" or "DefaultKind" written after the object's other properties
threw NotSupportedException out of SchemaSerializer.Load, which promises a
result. The serializer now accepts metadata in any position, and a type with
no discriminator it recognises is reported as InvalidJson, so the CLI says
"Could not open" instead of crashing.

Fixes #287

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
@matt-edmondson matt-edmondson changed the title Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse nulls in C++ options Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse unreadable input instead of throwing Oct 19, 2026
…s path

A null in a hand-written file reached the model as a null its non-nullable
properties never expect, so Load or Validate threw. Load now drops null
properties before deserializing, so they take the same default an omitted
property does, and reports a null list element as InvalidJson naming it.

Fixes #242

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
A class that contains itself directly, or through a chain of other classes,
an Optional or a Result, has no finite size and cannot be generated in any
target. Validation now reports one error per cycle, naming the route, and
points at an Array or a Handle as the way to write a recursive structure.

Fixes #293

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1AYPNe9rDK8FtuQgiKTnT
@matt-edmondson matt-edmondson changed the title Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse unreadable input instead of throwing Move the editor onto ktsu.UndoRedo 3, stop saves and loads from silently losing a schema, and refuse unreadable or unsizable input Oct 19, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment