Skip to content

Make the models implement Schema.Contracts (#110) - #129

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/schema-issues-kwr4lk
Aug 27, 2026
Merged

Make the models implement Schema.Contracts (#110)#129
matt-edmondson merged 1 commit into
mainfrom
claude/schema-issues-kwr4lk

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Closes #110 by taking option (a), per your decision. The reasoning is recorded on the issue; this is the short version.

Breaking change — needs a major version bump.

The interfaces were not implementable as written

ISchema.Classes was typed ISchemaChildSet<ISchemaClass, ISchemaClassName>, and ISchemaChildSet<TValue, TName> : ISet<TValue>. ISet<T> is invariant, because Add(T) puts T in an input position — so SchemaChildSet<SchemaClass, ClassName> is not an ISchemaChildSet<ISchemaClass, ISchemaClassName>, and no arrangement of type parameters makes it one.

So the set becomes covariant in its element type, dropping the mutating members that forced invariance:

public interface ISchemaChildSet<out TValue, in TName> : IReadOnlyCollection<TValue>
{
    TValue? GetByName(TName name);
    bool ContainsByName(TName name);
}

The lookup returns its result rather than using an out parameter — C# treats those as invariant positions too. Mutation moves onto the owning element (ISchema.AddClass, ISchemaClass.AddMember), which is where it belonged anyway: those are the choke points that enforce name uniqueness and give a new element the parent reference it needs to resolve its own type references.

TName stays invariant, so the contracts name the concrete ClassName, not ISchemaClassName. Entities are abstracted; values are not. A semantic string is already an abstraction over string; wrapping it again buys nothing and is exactly what made the variance unworkable. The five name interfaces that are load-bearing as generic constraints stay; the seven that existed only to be named in type arguments go, with ISchemaChildDescription and ISchemaChildSummary.

Three contracts described something the library doesn't have

Reshaped to reality rather than bending the models to fit:

Contract Claimed Actually
ISchemaChild.Summary every element has a summary no model has one
ISchemaEnum.Values values are child elements the format stores a list of strings
ISchemaType derives from ISchemaMemberChild BaseType has no name, description or parent class
  • Summary is dropped rather than added: adding it means a new serialized field on every element, immediately after Editor: descriptions are stored and serialized but never editable or displayed #113 collapsed two overlapping description fields into one. Say the word if you'd rather have it — it's additive and cheap.
  • SchemaEnumValue described a shape nothing produces, and is removed with ISchemaEnumValue.
  • ISchemaType now carries what a type actually has: which type it is, and the member holding it. SchemaMemberChild had no derived types left and goes too.

That last one gives BaseTypeName a job it never had — BaseType.TypeName reports the discriminator written to the file, derived from the same CLR type name the [JsonDerivedType] attributes are declared with. A test asserts the two agree across all seventeen types, so they can't drift.

SchemaChildSet was rewritten, not adopted

It was backed by HashSet<T>, which is unordered. Using it as the model's backing store as the issue suggested would have broken member reordering (#118), and would have reordered a class's members after any remove-then-add — which is what undoing a deletion does. HashSet preserving insertion order absent removals is an implementation detail, not a guarantee.

It's now an order-preserving view over the collection its owner serializes. As a view there's no second copy to diverge and no change to the file format. It owns the name-uniqueness rule that SchemaClass.AddMember, SchemaClass.RestoreMember, Schema.AddChild and Schema.RestoreChild each re-implemented as an Any(x => x.Name == name) check.

Uniqueness is enforced on the way in, not on the way through. Deserialization writes to the underlying collection directly, so a hand-edited file containing duplicate names still loads with both present and is reported by Validate(). Silently de-duplicating at load would turn a diagnosable mistake into data loss — the same failure mode as the dropped member type fixed in #124. There's a test for it.

Also removed, per the issue

RootSchemaMember (never instantiated) and SchemaTypes.TypeQualifier (returned a nested-type prefix for a layout the types no longer use).

Docs

docs/examples/dependency-injection.md now shows injecting ISchema and states plainly what the contract does not cover (serialization, validation, path resolution, data sources, generators — take Schema for those).

CLAUDE.md's type hierarchy is corrected: it claimed BaseType : SchemaMemberChild<BaseTypeName>, which hasn't been true.

Verification

299 tests pass on net8.0, net9.0 and net10.0 (287 before). The 12 new ones cover defining a schema through ISchema alone, navigating the model in both directions through the contracts, the covariance actually holding and the view being live rather than a snapshot, uniqueness and removal through the contract, rejecting a foreign ISchemaType, TypeName matching the serialized discriminator, insertion order, order after remove-and-restore, duplicate rejection, bounds-checked moves, and duplicates in a file surviving load to be reported.

SonarAnalyzer.CSharp run locally over the library after the change: every pre-existing rule count is at or below baseline and no new rule appears — S3236 drops 36 → 2, S1450 and S1133 go to zero.

On the breakage

Nothing in the package could hand a consumer any of the removed interfaces — no model implemented them — so code referencing them had to supply its own implementations. The practical break is narrower than the count of removed public types suggests.

Closes #110

🤖 Generated with Claude Code

https://claude.ai/code/session_012WmzGm9XniSoqaiVGDT6qT


Generated by Claude Code

Closes #110 by taking option (a): the contracts become a real seam rather
than 24 public interfaces nothing implements.

The interfaces were not implementable as written. ISchema.Classes was typed
ISchemaChildSet<ISchemaClass, ISchemaClassName>, and ISchemaChildSet derived
from ISet<T>, which is invariant because Add(T) puts T in an input position.
No arrangement of type parameters makes a set of concrete elements assignable
to a set of interfaces.

ISchemaChildSet is now covariant in its element type and drops the mutating
members that forced invariance. Its lookup returns the element rather than
using an out parameter, which C# also treats as an invariant position.
Mutation moves onto the owning element - ISchema.AddClass,
ISchemaClass.AddMember - which is where it belongs anyway: those are the
choke points that enforce name uniqueness and give a new element the parent
reference it needs to resolve its own type references.

That leaves the name type invariant, so the contracts name the concrete
ClassName rather than an ISchemaClassName. Entities are abstracted; values
are not. A semantic string is already an abstraction over string, and
wrapping it again buys nothing while making the variance unworkable. The
five name interfaces used as generic constraints stay; the seven that only
existed to be named in type arguments go, along with ISchemaChildDescription
and ISchemaChildSummary.

Three contracts described something the library does not have, and are
reshaped to reality rather than the models being bent to fit:

- ISchemaChild required a Summary no model has. Adding one would mean a new
  serialized field on every element, immediately after #113 collapsed two
  overlapping description fields into one. Dropped, with the unused
  SchemaChildSummary record.
- ISchemaEnum exposed values as child elements. The format stores them as a
  list of strings, so they are exposed as names. SchemaEnumValue described a
  shape nothing produces and is removed.
- ISchemaType derived from ISchemaMemberChild, but BaseType has never been a
  member child - it has no name, description or parent class. It is now its
  own interface carrying what a type actually has: which type it is, and the
  member holding it. SchemaMemberChild had no derived types left and goes
  too.

BaseType.TypeName gives BaseTypeName a job it did not have: it reports the
discriminator written to the file, derived from the CLR type name that the
[JsonDerivedType] attributes are declared with. A test asserts the two agree
for all seventeen types.

SchemaChildSet is rewritten as an order-preserving view over the collection
its owner serializes. It was backed by a HashSet, which is unordered:
adopting it as the backing store would have broken member reordering (#118),
and would have reordered a class's members after any remove-then-add - which
is what undoing a deletion does. As a view there is also no second copy to
diverge from the serialized one, and no change to the file format.

It now owns the name-uniqueness rule that SchemaClass.AddMember,
SchemaClass.RestoreMember, Schema.AddChild and Schema.RestoreChild each
re-implemented as an Any(x => x.Name == name) check.

Uniqueness is enforced on the way in, not on the way through: deserialization
writes to the underlying collection directly, so a hand-edited file with
duplicate names still loads with both elements present and is reported by
Validate(). Silently dropping one at load would turn a diagnosable mistake
into data loss - the same failure mode as the dropped member type fixed in
Schema/Models/SchemaMember.cs.

Also removes RootSchemaMember, never instantiated anywhere, and
SchemaTypes.TypeQualifier, which returned a nested-type prefix for a layout
the types no longer use.

docs/examples/dependency-injection.md now shows injecting ISchema and says
what the contract does not cover. CLAUDE.md's type hierarchy is corrected -
it claimed BaseType derived from SchemaMemberChild<BaseTypeName>, which has
not been true.

Breaking: public interfaces and types are removed and ISchemaChildSet changes
shape. Nothing in the package could hand a consumer any of the removed
interfaces, so code using them had to supply its own implementations. Lands
with a major version bump.

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit daaac34 into main Aug 27, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/schema-issues-kwr4lk branch August 27, 2026 23:56
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.

Remove or wire up the unused Schema.Contracts API surface and its dead helpers

2 participants