Skip to content

Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones - #166

Merged
wol-soft merged 54 commits into
masterfrom
claude/mr-74-review-adoption-3p8hdm
Aug 12, 2026
Merged

Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones#166
wol-soft merged 54 commits into
masterfrom
claude/mr-74-review-adoption-3p8hdm

Conversation

@wol-soft

@wol-soft wol-soft commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Summary

Gives the generator a principled way to decide whether a schema without an explicit
type: object is, statically, an object — and to fail generation loudly instead of silently
misbehaving when it isn't. Fixes the composition bugs originally reported against a FedEx OpenAPI
schema, plus several pre-existing defects found while verifying that work.

Fixed bugs

Issue Bug Symptom before
#72 Object-implied compositions were not recognised Generation crashed with No nested schema for composed property, or the composed property was never instantiated (getCEO() returned a raw array despite a nested @return)
#182 A base-level $ref dropped the referenced schema's object-level constraints additionalProperties, minProperties, propertyNames and any root composition were never validated; an object could be constructed in an invalid state and only fail later with a PHP TypeError from a typed getter
#183 A base-level $ref to a root anyOf/oneOf was always rejected A referenced schema on base level must provide an object definition, even when every branch declared type: object. The same composition written inline worked — a $ref did not behave like the schema it points at
#184 A literal true composition branch never warned {}, example-only and annotation-only branches warned; the most explicit "matches anything" spelling stayed silent

Fixes #72, fixes #182, fixes #183, fixes #184.

Not filed as issues because they were introduced and fixed inside this branch, so no release ever
contained them: wrong composition-element numbering in direct-exception mode, a target-side
generation failure being reported as Unresolved Reference, an unreachable guard in
withoutNestedCompositionValidation(), and the representability check pre-empting three precise
diagnostics (see Undecidable shapes below).

A later review pass on this branch found four more of the same kind, all fixed here:

  • An allOf branch declaring a type list containing "object" was rejected as conflicting with
    an object-asserting sibling — so allOf: [<object>, {"type": ["object", "null"]}], the ordinary
    "referenced type, but nullable" shape, failed generation. The conflict check inferred "scalar
    branch" from the absence of a nested schema, which a multi-type branch also lacks.
  • Two cross-file $ref shapes broke because the representability check ran before the schema was
    registered for deduplication, and its $ref peek re-enters processTopLevelSchema(): two files
    referencing each other recursed until the stack was exhausted, and a target referencing back built
    a duplicate render job (File X.php already exists).
  • A composition keyword given as a JSON object instead of an array raised a raw
    TypeError: Unsupported operand types: string + int from the new 1-based branch numbering. It,
    and a branch that is not a schema at all, now raise a SchemaException naming the keyword and
    branch. (not and if/then/else crashed the same way on a scalar branch — pre-existing, but
    hardened alongside.)

How it works

ObjectShape and ObjectShapeResolver statically classify a schema by resolving $ref chains and
composition keywords. Wired into PropertyFactory::create() so that:

  • An allOf whose branches jointly guarantee object-ness is routed through the real object path —
    a generated class, instantiated, validated with instanceof — even when no individual branch
    declares type: object.
  • A schema carrying only object-constraining keywords with no type gets a guarded
    representation: object values are instantiated and validated, non-object values pass through
    unchanged per strict JSON Schema semantics, with a generation-time warning since this is easy to
    write by accident.
  • allOf branches with contradictory types (an object-asserting branch alongside an explicit
    scalar-typed branch) are rejected at generation time instead of producing silently broken code.
  • The vacuous-branch warning is driven by the Draft's own registered validator keywords rather than
    a hardcoded list, so it works for custom Drafts.

Class-defining compositions must resolve to a definite object

Excluding "vacuous" (e.g. example-only) branches from composition matching was rejected during
review as not spec-compliant — a schema that also matches non-object values genuinely cannot be
represented by the single class a file root, or a $ref target parsed as its own schema, produces.

SchemaProcessor::checkObjectRepresentability() closes that gap the spec-compliant way: a
class-defining composition that does not resolve to ObjectAsserting is rejected, unless
GeneratorConfiguration::setImplicitObjectComposition() is enabled (default false) as an opt-in
escape hatch for object-describing compositions. ObjectShapeResolver classifies if/then/else
for this too — an aggregate asserts object-ness only when both then and else do.

Undecidable shapes

The classifier separates "decidably not an object" from "object-ness cannot be determined here"
(an unresolvable or cyclic $ref, a filter-bearing schema). Only the former is rejected;
an undecidable shape is handed back to the pipeline so the subsystem that owns it — $ref
resolution, the filter machinery — reports its own precise, correctly-attributed error.

This matters because the representability check runs before any property processing, so without the
distinction it pre-empted the unresolved-reference and filter-compatibility diagnostics with a
generic message naming the wrong cause. A $ref to a boolean definition is deliberately not
undecidable: JsonSchema::$json is typed array, so the target is known and unrepresentable, and
routing it back to the pipeline would resurface the uncaught TypeError this branch replaced with
a clean SchemaException.

$ref siblings follow the draft

Since the $ref-sibling rework landed on master, whether keywords beside a $ref apply is decided
per draft: Draft 07 registers $ref as an ExclusiveProducer and ignores them, Draft 2019-09 and
later apply them alongside the reference. ObjectShapeResolver::classifyReference() reads the
draft's own $ref producer and follows it rather than restating the policy — a classifier that
merged siblings regardless would reject allOf: [{$ref: <object>, "type": "string"}] under Draft 07,
which the generator builds without complaint because the type is ignored there.

checkObjectRepresentability()'s $ref exemption covers sibling-bearing roots for the same reason:
the reference resolution that follows already implements whichever rule is in force.

Breaking changes

Release notes are composed separately for 1.0; these are the entries this PR contributes.

  • A class-defining composition that only describes object shape now raises a SchemaException.
    This hits the common OpenAPI inheritance shape when the base omits type: object, and a schema
    root carrying properties plus if/then without else. Fix by adding "type": "object", or
    by enabling setImplicitObjectComposition(true). Two schemas in this repo's own test suite
    needed "type": "object" added.

  • Three further root shapes that previously generated are now rejected. The opt-in flag provably
    cannot rescue any of them (it only widens acceptance from asserting to describing), so the message
    names declaring the type as the fix but deliberately does not offer the flag:

    • {"type": ["object", "null"], ...} — a multi-type root permits a non-object value the single
      generated class cannot represent. The generated constructor only ever took an array, so such a
      root previously produced a class that could not accept its own schema's null.
    • a composition containing a vacuous branch ({}, true, annotation-only), which matches every
      value.
    • an empty composition ({"allOf": []}, {"anyOf": []}), which constrains nothing at all.
      Previously a warning plus a class that accepted anything.

    Add "type": "object" (or remove the vacuous branch) to generate these. The suggestion is worded
    conditionally in the message, because unlike the object-describing case above these schemas really
    do accept non-object values — declaring the type changes what they accept rather than stating what
    they already mean, and only the author knows whether that was the intent.

  • An array items schema carrying only object-constraining keywords with no type now gets a
    guarded representation class: object items are instantiated and validated, non-object items pass
    through unchanged per strict spec, and generation warns. Previously no item class was generated at
    all and the item constraints were silently dropped, so input that used to be accepted may now be
    correctly rejected.

  • An allOf of $refs to object schemas no longer produces a _Merged_ class. Such a composition
    guarantees an object, so it is routed through the object path and the property is typed with a
    regular nested class instead (X_Merged_CeoX_Ceo), which also changes the accessor
    signatures from mixed to that class. This makes the $ref form behave like the inline form,
    which already generated a plain nested class. anyOf is unaffected and remains the only keyword
    that produces a merged property.

  • An allOf-implied object property carrying an object default now fails generation, consistent
    with an explicit type: object property, which has always rejected object defaults.

  • Composition error messages in direct-exception mode now enumerate every branch with its reason,
    matching collect-errors mode — except for a root composition generated with
    setImmutable(false), where both composition templates still gate the enumeration on
    isMutableBaseValidator. That gap is pinned by a test and tracked in the follow-up.

  • Composition branch numbering in the vacuous-branch warning and in the filter-in-branch
    SchemaException is now 1-based, matching the runtime Composition element #N numbering.
    Previously both counted from zero.

  • A base-level $ref now enforces the referenced schema's object-level constraints (A base-level $ref silently drops the referenced schema's object-level constraints #182), so
    input that was previously accepted may now be correctly rejected. Such a $ref to a root
    anyOf/oneOf now generates instead of being refused (A base-level $ref to a root anyOf/oneOf is always rejected, even when it is a definite object #183), and a failure inside the
    referenced schema reports that schema's own error rather than Unresolved Reference.

  • A literal true composition branch now emits the vacuous-branch warning (A literal true composition branch never emits the vacuous-branch warning #184).

Known follow-up

#181, shipping in the
same release, so the two land as a single migration. Deliberately not auto-closed by this PR:

  • The representability check and its flag currently affect the file root and cross-file $ref
    targets only; named properties, array items and dependencies targets bypass it. A root carrying
    only object keywords with no composition keyword is skipped entirely — no class, no message —
    under either setting of the flag.
  • The composition branch type-inheritance mechanism (inheritPropertyType()) is largely redundant
    once representability is validated upfront, and actively corrupts a branch in two proven cases:
    an allOf branch with a scalar enum becomes an unsatisfiable {type: object, enum: [...]}
    (this PR warns about it; removing the injection is the fix), and the injected type suppresses the
    vacuous-branch warning for every non-boolean spelling.
  • $ref to a boolean schema cannot be represented, because JsonSchema cannot carry a boolean.
    A reproduction of the named-property variant (which escapes as an uncaught TypeError, unlike the
    root variant's clean SchemaException) is recorded in the Follow-up: broaden object-representability checks and reconsider forced type inheritance in compositions #181 thread.
  • Branch enumeration in direct-exception mode skips mutable base validators.

Filed separately, since it is independent of #181 and pre-existing on master:
#189{"type": ["object"]} is classified as an object by ObjectShapeResolver but cannot be routed as one, so
such a schema either fails with a misattributed No nested schema for composed property or is
skipped with no diagnostic at all. This branch makes the divergence visible rather than causing it:
the representability check now approves a root the pipeline then refuses to build.

Test plan

  • Merged with master at 7cff736 (the $ref-sibling rework and the multi-draft infrastructure).
  • Full suite green — 3230 tests, 8197 assertions, no failures — and phpcs clean on every file this
    branch touches. CI green on PHP 8.4 and 8.5.
  • New/updated coverage: ObjectShapeResolverTest, ComposedObjectShapeValidationTest,
    ComposedAllOfTest, Issue72Test, ReferencePropertyTest, ComposedOneOfBranchDefaultTest.
  • Every defect found in review has a regression test: the multi-type branch in both its inline and
    $ref spellings, the two cross-file recursion shapes, malformed composition keywords across
    allOf/anyOf/oneOf/not/then, the empty-composition roots, the bare object-describing array
    item, and the dependencies site's force-asserting asymmetry (which was recorded nowhere before).
  • Two shapes found while probing are filed as issues rather than pinned as tests: a test asserting a
    TypeError or a misattributed message documents a bug as if it were a contract.
  • Inline/$ref parity is asserted through a shared assertion helper covering both accepted
    branches and both rejection modes, so the two forms must behave identically rather than merely
    both generating.
  • Each restored diagnostic is asserted against the owning subsystem's own message, not the
    representability message, so a regression surfaces as a named failure.
  • The draft-dependent $ref sibling policy is pinned both as a unit matrix (Draft 07 vs 2019-09)
    and end to end.
  • See docs/source/combinedSchemas/impliedObjects.rst and docs/source/gettingStarted.rst for the
    user-facing documentation, including setImplicitObjectComposition().
  • mergedProperty.rst, allOf.rst and oneOf.rst were corrected against generated output rather
    than from reading the code: anyOf is the only keyword that produces a merged property
    (OneOfValidatorFactory never calls createMergedProperty() at all), and the page's headline
    allOf example had documented a _Merged_ class that is not generated. The allOf-inline and
    oneOf claims were already stale on master; only the $ref-based allOf form changed here.

@wol-soft wol-soft mentioned this pull request Jul 16, 2026
@coveralls

coveralls commented Jul 16, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31640503194

Coverage increased (+0.07%) to 98.88%

Details

  • Coverage increased (+0.07%) from the base build.
  • Patch coverage: 4 uncovered changes across 3 files (453 of 457 lines covered, 99.12%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/SchemaProcessor/SchemaProcessor.php 76 74 97.37%
src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php 175 174 99.43%
src/PropertyProcessor/ObjectShape/ObjectShapeResolver.php 90 89 98.89%
Total (15 files) 457 453 99.12%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 8037
Covered Lines: 7947
Line Coverage: 98.88%
Coverage Strength: 594.55 hits per line

💛 - Coveralls

Comment thread src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php Outdated
Comment thread src/PropertyProcessor/ObjectShape/ObjectShape.php Outdated
Comment thread tests/Issues/Issue/Issue72Test.php
Comment thread src/PropertyProcessor/ObjectShape/ObjectShape.php Outdated
Comment thread src/PropertyProcessor/ObjectShape/ObjectShapeResolver.php Outdated
Comment thread src/Templates/Validator/ComposedItem.phptpl Outdated
Comment thread tests/ComposedValue/ComposedAllOfTest.php Outdated
Comment thread tests/Issues/Issue/Issue72Test.php Outdated
Comment thread tests/Issues/Issue/Issue72Test.php Outdated
Comment thread src/PropertyProcessor/ObjectShape/BranchObjectShape.php
wol-soft added a commit that referenced this pull request Jul 24, 2026
- Drop the example-only composition-branch exclusion: it wasn't spec-compliant
  and raised more questions than it solved (metadata keywords, generalization).
  A branch like that now surfaces as an ordinary vacuous branch instead.
- Classify a multi-type array whose sole listed type is "object" as
  ObjectAsserting, matching the plain "object" string; mixed multi-type
  (e.g. ["object", "null"]) still correctly blocks, since a value satisfying
  such a branch need not be an object.
- Clarify the ObjectShape docblock: ObjectDescribing schemas ARE routed
  through a (guarded) object-instantiation path for object values, and a
  `type` that merely permits "object" among others is NotObject, not
  ObjectDescribing.
- Add a TODO on OBJECT_DESCRIBING_KEYWORDS explaining why it can't yet be
  derived from the Draft (no reverse "keywords for this type" lookup).
- Assert getter/setter type hints and annotations on the standalone
  object-describing property test, plus JSON-array pass-through coverage now
  that the array/object type-guard fix (PR #170) is in.
- Move template-authoring rationale comments in ComposedItem.phptpl behind
  `{# #}` so they're stripped from generated code, matching the file's own
  existing convention.
- Replace string-concatenation-built regexes with heredocs across the
  composition tests touched by this branch.
- Trim implementation-process narration (phase numbers, "expected to pass
  already", planning-doc pointers) from Issue72Test docblocks in favor of
  describing the tested behavior itself.
claude and others added 26 commits July 27, 2026 00:35
PR #74 targeted two composition bugs reported against a FedEx OpenAPI schema:
a crash on deeply nested allOf $ref chains, and oneOf branches containing only
an "example" keyword matching every input. The PR's own code no longer applies
(the composition architecture it patched was fully rewritten since), so only
its test schemas are reconstructed here against current master, pinning the
verified current behavior:

- Deeply nested allOf no longer crashes, but the composed property is never
  instantiated as an object (getCEO() returns a raw array despite a nested
  @return annotation).
- A root-level oneOf with an example-only branch still fails generation with
  "No nested schema for composed property" - a stricter form of the original
  crash.
- The same branch nested inside a property lets generation succeed, but the
  example-only branch is never skipped during validation, so valid input is
  rejected for matching two branches instead of one.

See .claude/issues/72/analysis.md and implementation-plan.md for the full
investigation and the patches still needed; no fix is applied in this commit.
A closer investigation, prompted by rethinking the original plan for
overlooked scenarios, found the earlier framing of the oneOf/allOf defects
was imprecise in ways that mattered:

- The root-level "No nested schema" crash is specifically caused by $ref
  siblings losing their inherited type (Draft 7 semantics), not by "any
  untyped branch" - an inline equivalent branch generates fine but still
  always over-matches at runtime, same as the nested case.
- The still-open example-only-branch bug has two independent symptoms that
  both need covering: valid data gets rejected (over-match), and the
  originally reported symptom - bare scalars still get silently accepted -
  is still fully live on master.
- Verifying that relaxing the root-level crash would be safe surfaced a
  separate, broader gap: allOf conflicts between an object-shaped branch and
  a scalar-typed branch are not detected anywhere (root or nested), because
  the existing type-conflict diagnostic only looks at scalar-typed branches.
  Naively relaxing the crash would silently swallow this too.

Four new schemas/tests pin these findings; all ten Issue72Test cases pass
today, characterizing current behavior. See .claude/issues/72/analysis.md
and implementation-plan.md for the full writeup and the open questions that
need answers before any fix is implemented.
transferPropertyType() returned immediately whenever any allOf branch had a
nested schema, before its own conflicting-types check ever ran - so an allOf
mixing an object-shaped branch with an incompatible scalar-typed branch (e.g.
object vs string) went completely undetected. At the schema root this fell
through to a confusing, unrelated "No nested schema for composed property"
crash; nested inside a property it generated successfully into a validator
that could never be satisfied by any input at runtime.

Add a dedicated check for this case, reusing the existing "conflicting types
in allOf composition branches" diagnostic (factored into a shared helper) so
both scalar-vs-scalar and object-vs-scalar conflicts now produce the same
clear, generation-time error. anyOf/oneOf are unaffected - a value satisfying
either an object shape or a scalar type is legitimate union semantics there.

This is a prerequisite for the still-open issue #72 example-only branch fix:
relaxing the crash naively (without this) would have silently swallowed
genuine allOf type contradictions instead of diagnosing them.
This reverts commit e3e69c9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141ZMWi8mp4JDGVwScPaVtP
…orrect behavior

- transferPropertyType(): drop the separate hasNestedSchemaBranch flag and run
  the allOf conflict check directly inside the loop instead of after it.
- Rename the single-character $p parameter to $compositionProperty in the new
  assertNoObjectScalarTypeConflict() closure.
- Rewrite every Issue72Test case to assert the correct, desired behavior
  instead of pinning today's buggy output. Tests for phases not yet
  implemented are now expected to fail (red) until their fix lands; the two
  allOf conflict tests already pass since Phase 0 covers them. This means the
  full suite will show failures until Phases 1-3 land - that is intentional.
…sue #72)

transferComposedPropertiesToSchema() required every composed branch to have
a nested schema unconditionally, so a $ref'd branch resolving to only
annotation keywords (no type, no properties) crashed generation instead of
being treated as a branch that matches anything and contributes no named
properties.

Only relax this for branches with no explicit type at all: a branch that
still declares a conflicting scalar type without a nested schema keeps
throwing exactly as before (existing test
ComposedAllOfTest::testNoNestedSchemaThrowsAnException relies on this for a
root-level allOf with a single conflicting integer branch, which never goes
through Phase 0's object-vs-scalar check since there's no object branch to
conflict with).

Skipping a branch still needs to feed the same "last branch resolved"
bookkeeping the normal path uses, so that logic is extracted into a shared
finalizeComposedBranchResolution() helper used by both paths.

This turns the referenced-example-only-branch crash into the same
"OneOfException: matched 2 elements" state already produced by the inline
and nested variants - expected, since excluding the branch during validation
is Phase 2's job, not this one's. Full suite: same 2871 passing / 3 errors /
3 failures as before this change, confirmed to be the identical 6 test
methods with no new regressions.
A oneOf/anyOf/if branch whose $ref-resolved content is exactly {"example":
...} carries no constraint at all, so it always matches - defeating oneOf's
"exactly one" semantics (rejecting otherwise-valid data that also matches a
real branch) and anyOf's "at least one" semantics (silently accepting any
value, the literal bug issue #72 reported). Exclude that one explicitly
named shape from the branch array before validator construction, rather than
letting it participate.

Deliberately narrow: only the literal "example" keyword is whitelisted, not
a general "annotation-only branch" classifier - JSON Schema doesn't define
annotation-only branches as matching nothing, so silently expanding this by
concept would misrepresent schemas that are actually spec-valid, if unusual.
Any future addition to the whitelist should be its own deliberate, reasoned
decision.

Testing surfaced one exception this narrow rule already had to account for:
inheritPropertyType() injects the parent's type into every untyped branch
before this check runs, and only a $ref branch loses that sibling on
resolution (Draft 7 drops keywords next to $ref) - an inline branch keeps
it, resolving to {"example": ..., "type": "object"} instead of just
{"example": ...}. Both are the same author-written shape; only our own
bookkeeping differs, so both are recognized as the same whitelisted case.

Also adds a broad, non-behavior-changing warning for any composition branch
(any keyword, including allOf) that carries no validation/assertion keyword
at all after $ref resolution, so schema authors are alerted to vacuous
branches the narrow exclusion deliberately leaves untouched.

Inspecting a branch's resolved JSON is only safe once its
CompositionPropertyDecorator reports isResolved() - a branch still pending
resolution is necessarily part of a recursive $ref chain (it takes
structural content to recurse through, which a vacuous branch can never
have), so gating on that flag loses no coverage while avoiding a possible
fatal error against an unresolved proxy.

Full suite: 2871 tests, only the still-open Phase 3 (nested allOf object
instantiation) test failing, no other regressions.
… list

warnIfVacuousBranch() compared branch keys against a hardcoded
ANNOTATION_ONLY_KEYWORDS blocklist, treating any unlisted key as "a real
constraint" by default. That's backwards and exploitable: an unrecognized or
misspelled key would silently defeat the warning for exactly the schemas it
exists to catch.

Derive vacuousness from the Draft's own registered validators instead
(Draft::getTypesForKeyword()), so every real assertion keyword is
recognized automatically rather than requiring each one to be anticipated
and listed. 'type' and 'const' need a small, explicit exception since
Draft_07 wires them via Type::addModifier() (numeric-keyed) rather than
addValidator() (keyword-keyed), making them invisible to
getTypesForKeyword() despite being genuine constraints; 'default' is
registered the same way but correctly needs no exception since it doesn't
validate anything.

Verified directly with a recording logger: a branch with an unrecognized
key now warns, const-only and type-only branches correctly do not. Full
suite: 2872 tests (one new), only the still-open Phase 3 test failing, no
regressions.
Draft resolution (DraftFactoryInterface vs DraftInterface) plus building and
caching the immutable registry was duplicated three times: PropertyFactory
and FilterProcessor each had their own private resolveBuiltDraft(), and the
composition warning added in the prior commit introduced a third copy.
PropertyFactory's own cache barely helped in practice since the class is
instantiated fresh throughout the codebase; FilterProcessor didn't cache
across calls at all.

Add GeneratorConfiguration::getBuiltDraft(), cached per concrete
DraftInterface class on the GeneratorConfiguration itself - the one
genuinely singleton-per-run object - and have all three call sites use it
instead of maintaining their own copy.

Also inline isVacuousBranch() into its only caller, warnIfVacuousBranch(),
since splitting it into a separate method added a layer of indirection
without adding clarity.

Full suite: 2872 tests, same single (expected, Phase 3) failure, no
regressions.
…#72)

The still-open allOf defect (a property built from composition-only
definitions never instantiates its value) raised the question whether the
other composition keywords suffer comparable issues. Verified by comparing
each keyword against its explicit-object-branch equivalent (the gold
standard, which works today): all of them are broken when branches are $refs
to composition-only definitions, each in its own way.

- anyOf: silently accepts everything (including values matching no branch
  and bare scalars) and never instantiates - the literal over-acceptance
  reported in issue #72.
- oneOf: rejects everything ("matched 2 elements" - both branches, stripped
  of their validators, trivially match any value).
- if/then/else: the condition routes correctly, but the taken branch
  enforces nothing and the value stays a raw array.
- not: rejects everything (the stripped forbidden-schema always "matches",
  inverting into a full rejection).

The mechanism is the same in all cases: a composition-implied branch has no
generated class, so its composed validator is stripped on the assumption an
instantiation would re-validate it, and no per-branch instantiation exists.

Ten new test cases assert the gold-standard behavior (object instantiation
where the explicit equivalent instantiates, raw array for not, correct
accept/reject decisions). Eight are red until the underlying fix lands; the
two green ones (oneOf/not rejection cases) produce the right result today
for the wrong reason. Full suite: 2882 tests, exactly the 8 intentional
Issue72Test reds, nothing else affected.

The related nested-composition validation hole (nested composition keywords
inside untyped property-level branches validate nothing at all) is reported
separately as issue #167.
…are shapes

Second verification round for the composition-implied object defects, again
comparing each shape against its explicit-object gold standard (verified
working) before asserting:

- Inline implied branches (compositions written directly into the branch
  array instead of $ref'd definitions) fail identically to the $ref
  variants. The existing per-keyword tests now cover both forms via
  schema-file data providers.
- Mixed compositions (implied-object branch + scalar branch) need no
  separate mechanism once branches are real - the explicit equivalents
  already dispatch per value correctly. Current behavior however is severely
  broken: mixed anyOf accepts everything, mixed oneOf is fully inverted
  (rejects the valid string via "matched 2", accepts an invalid integer
  matching only the stripped branch), and a mixed if/then/else enforces
  nothing on the taken then-branch.
- allOf mixing an implied-object branch with a scalar branch is
  unsatisfiable and must fail generation with the existing conflicting-types
  diagnostic; currently the conflict is invisible (the implied branch
  exposes neither a nested schema nor a type) and the generated model
  inverts the schema's intent - accepting plain strings while rejecting the
  described objects.
- Branches carrying only object validators (properties/required, no type)
  are equally dead: bare-oneOf rejects every value including valid ones,
  bare-anyOf accepts spec-invalid values (an empty object failing required
  in both branches). Tests cover only outcomes on which strict-spec and
  object-implied semantics agree; the one divergent case (non-object values
  in bare-anyOf: spec accepts via vacuous matches, object-implied rejects)
  is deliberately excluded pending a design decision.

Issue72Test now: 43 cases, 26 red until the underlying fix lands, 17 green.
Full suite: 2906 tests, all reds confined to Issue72Test.
Replace the generic expectException(ValidationException) assertions with the
concrete exception classes (AnyOf/OneOf/Not/ConditionalException) and their
complete messages. The expected messages were captured from the
explicit-object-branch gold-standard equivalents generated with the test
harness configuration - since the fixed implementation routes implied
branches through the identical outer machinery, these are the exact messages
it must produce, including the per-case matched-element counts for oneOf.

The stronger assertions immediately paid off by flipping two cases that
passed for the wrong reason: an object matching neither implied oneOf branch
is currently rejected with "matched 2 elements" (both stripped branches
trivially match) where the correct diagnostic is "matched 0 elements" - now
red until the fix lands.

Also encodes the resolved design decision for bare object-validator branches
(properties/required without type) with a dedicated test: strict JSON Schema
semantics apply, so a NON-object value in such an anyOf is ACCEPTED via
vacuous branch matches (rejecting it by treating the branches as
object-implied was considered and rejected - spec overrides must stay
narrowly whitelisted opt-ins, and authors meaning objects can declare
type: object). Consistently, a non-object in the bare oneOf variant is
expected to be rejected for matching BOTH branches vacuously (matched 2),
not for matching none.

Issue72Test now: 44 cases, 29 red until the fix lands, 15 green. Full
suite: 2907 tests, all reds confined to Issue72Test.
Exception to the usual keep-planning-docs-out-of-git rule, explicitly
requested: this topic spans multiple long-running analysis documents
(defect analysis, nested-schema architecture investigation, phased
implementation plan) that must survive session breaks. The directory is
still expected to be removed in a final cleanup commit before this branch
merges.
Completeness review before implementation surfaced two remaining gaps, both
now closed:

- Array-items context verified: items referencing a single-level implied
  definition (allOf of explicit object branches) already work today, but
  multi-level implied items are broken identically to properties - valid
  items stay raw arrays and violations of the inner definition's required
  constraints are silently accepted. Pinned red by two new tests with the
  rejection message captured from the explicit-object gold equivalent.
  Other PropertyFactory entry contexts (dependencies, contains,
  patternProperties values) share the same mechanism and are deferred to
  the implementation test matrix.

- The object-shape predicate must be three-valued: the pinned bare-validator
  expectations (a non-object rejected for vacuously matching BOTH oneOf
  branches, while an accepted object still instantiates) are only jointly
  satisfiable by distinguishing object-ASSERTING schemas (explicit type or
  compositions thereof - non-objects fail, eligible for re-routing) from
  object-DESCRIBING schemas (bare properties/required - vacuous for
  non-objects, guarded validation plus a representation class, never
  re-routed or object-typed).

With that, the analysis is complete and the phased Phase 3 implementation
plan (P3.1 resolver, P3.2 re-routing + migration audit, P3.3 branch
validation restoration incl. issue #167, P3.4 conflict detection, P3.5
consumer sweep, P3.6 docs) is written out in the tracked notes.

Issue72Test: 46 cases, 31 red until the fix lands, 15 green. Full suite:
2909 tests, all reds confined to Issue72Test.
Foundation for routing composition-implied object schemas through the
object path, with no behavior change yet:

- ObjectShapeResolver statically classifies a raw schema as
  ObjectAsserting (explicit type: object, or a composition guaranteeing
  object-ness), ObjectDescribing (object keywords without a type -
  constrain objects, vacuous for non-objects per strict spec), or
  NotObject. $ref chains resolve through an injected callable; every
  uncertain case (unresolvable/cyclic refs, filter-bearing schemas,
  mixed-type unions) conservatively degrades so affected schemas keep
  their current processing path. Aggregation is conjunctive for allOf
  (one scalar branch poisons the aggregate - re-routing an unsatisfiable
  schema must never happen) and disjunctive for anyOf/oneOf (asserting
  only when every branch asserts).
- The internal four-valued branch shape distinguishes blocking branches
  (scalar types, false) from neutral ones (true, annotations) - the
  public three-valued enum cannot express that an allOf sibling must be
  poisoned by the former but not the latter.
- PropertyInterface::get/setNestedSchema() now documents the contract:
  the nested schema is the single class representing the property's
  object values, only ever set for exclusively-object-valued properties;
  multi-class compositions deliberately stay null (branch classes are
  reachable via the composed validator, and a list accessor would break
  the "non-null implies exactly one representing class" inference).

43 unit tests cover the shape table including reference chains, cycles,
sibling merging, and boolean branches. Full suite: 2952 tests, unchanged
26+5 intentional Issue72Test reds, no behavior change.
…sue #72, P3.2)

Re-route a composition-only property/branch/items schema that the
ObjectShapeResolver classifies as ObjectAsserting through the object path:
PropertyFactory::create() injects an explicit type: object and delegates to
createObjectProperty(), so an implied-object allOf becomes a genuine nested
class with instantiation and instanceof validation instead of a bare composed
validator. SchemaDefinition::getSource() exposes a $ref target's raw JSON so the
routing can classify it statically. This flips 23 issue #72 characterization
tests green; the remaining reds are the describing/bare-validator and
reject-message-shape cases handled by a later phase.

Two fixes surfaced by the change:

- Mixed object/scalar oneOf/if compositions crashed with a TypeError: a
  re-routed branch class's internal propertyValidationState leaked into the
  outer branch-default map, and the template fed a scalar composition input into
  array_key_exists. Exclude internal properties from the branch-default map and
  guard the ComposedItem branch-default loop with is_array().

- An all-object allOf with an enum branch lost its enum typing on the merged
  property, because base-level composition transfer blanks the branch JSON so the
  EnumPostProcessor no longer sees the enum. That blanking (added to stop
  disjunctive branch constraints leaking onto the merged property) is only
  correct for anyOf/oneOf/if; for allOf every branch applies to the same value,
  so gate the blanking to non-allOf compositions. Restores enum typing for both
  property-level and root-level allOf.

Migrate the tests affected by the intended behavior changes: the _Merged_ class
naming becomes regular nested-class names (ComposedAllOfTest), and property-level
composition errors become clearer nested-class instantiation / nested-composition
errors (ComposedAllOfTest, ArrayPropertyTest, Issue105Test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
…n classes (issue #72, P3.3 Group A)

A composition branch carrying only object-constraining keywords (properties,
required, ...) without a type keyword is object-describing: it constrains object
values but is vacuously satisfied by non-objects per strict JSON Schema
semantics. Such a branch was processed as untyped, so its object validators -
registered on the object Type - never ran and the branch trivially matched every
value (oneOf rejected everything, anyOf accepted everything).

Route a leaf describing schema (object-describing keywords, no type, no
composition/$ref, resolver says ObjectDescribing) through a guarded object path:
generate a representation class, instantiate it for object values via the
existing is_array($value) ? new X($value) : $value decorator, but omit the
asserting InstanceOfValidator so non-object values pass through unchanged. This
yields the strict-spec matched counts - a non-object matches every bare branch
vacuously (oneOf: rejected for matching >1, anyOf: accepted), an object is
validated against each branch's constraints - and instantiates a matching object.

Flips the 7 bare-validator characterization tests green with no regressions
across the full suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
…#72, P3.3 Group B)

In error-collection mode a composition exception enumerates each branch's
outcome and its underlying validation error; in direct-exception mode the
per-branch error registry was never populated, so the message stopped at the
matched-count summary ("declined by composition constraint. Requires to match
... but matched N elements"). For a composition-implied object branch - itself
validated through a generated composition class - this hid the leaf reason
(e.g. "Missing required value for name") behind one or more nested
"declined by composition constraint" summaries.

Populate compositionErrorCollection in direct mode too: push an empty registry
for a branch that validated cleanly (rendered "Valid") and, in the branch catch,
the branch's own ValidationException (rendered "Failed" with its message). The
existing exception rendering then reports every branch's outcome and reason,
recursively, so nested composition-implied object failures surface their leaf
cause. Gated away from the mutable base validator, whose state-cache fast path
skips branch evaluation and would misalign the registry.

Resolves the three issue #72 reject-message tests: the messages now correctly
show the composition layer (the implied-object definition genuinely is a
composition) together with the surfaced leaf reason. Migrate the object-level
ComposedAllOf/AnyOf/OneOf message tests, which now assert the per-branch detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
…rything (#167)

A composition branch's nested composition validator was stripped unconditionally
on the assumption that the branch's generated object class re-validates the
composition on instantiation. A scalar nested composition branch (an allOf/anyOf/
oneOf branch that is itself an anyOf/oneOf of scalar types) has no such class, so
the strip left it validating nothing: every invalid value was silently accepted
at construction (an int-typed getter merely surfaced a TypeError later on read).

Make the strip conditional on the branch having a nested schema, in both the
property-level filter (getCompositionProperties) and its root-level twin
(withoutNestedCompositionValidation): keep the nested composition validator when
no re-validating class exists. Because ComposedPropertyValidator is an
ExtractedMethodValidator, the retained validator renders as a scoped method call
rather than inline, so its composition counters do not clobber the outer
composition's. The root-level change also applies the same rule to
ConditionalPropertyValidator, removing the earlier accidental class-hierarchy
split where only nested if/then/else happened to survive.

Invalid scalar values now throw the appropriate composition exception at
construction, with the nested composition's leaf reasons surfaced in the message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
…s through (issue #72)

The guarded object path for object-describing schemas (object keywords without a
type) reused ObjectModifier, which types the property as the representation
class. That is correct only inside a composition, where the outer machinery
widens the type; a standalone describing property kept the object return type, so
a non-object value - which a describing schema accepts vacuously per strict spec -
validated cleanly at construction but then violated the getter's object return
type on read (a TypeError).

Reset the property to an open type in wireDescribingObjectProperty: the value is
either an instance of the representation class (object input) or the raw
non-object input, so the getter must not promise the class. Objects are still
instantiated and validated; non-objects pass through unchanged.

Add regression coverage for a standalone describing property (validates objects,
passes non-objects, rejects an invalid object).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
The merge pulled in a newer wol-soft/php-json-schema-model-generator-production
(dev-master) than what either branch's tests assumed - quoted identifiers, no
trailing periods, and pluralized composition counts. Update the affected
assertions (and the ArrayPropertyCombinedObject cases, where an all-object
composition is now routed through the object path and rejects a non-object
item directly instead of via a composition error) to match.
- Drop the example-only composition-branch exclusion: it wasn't spec-compliant
  and raised more questions than it solved (metadata keywords, generalization).
  A branch like that now surfaces as an ordinary vacuous branch instead.
- Classify a multi-type array whose sole listed type is "object" as
  ObjectAsserting, matching the plain "object" string; mixed multi-type
  (e.g. ["object", "null"]) still correctly blocks, since a value satisfying
  such a branch need not be an object.
- Clarify the ObjectShape docblock: ObjectDescribing schemas ARE routed
  through a (guarded) object-instantiation path for object values, and a
  `type` that merely permits "object" among others is NotObject, not
  ObjectDescribing.
- Add a TODO on OBJECT_DESCRIBING_KEYWORDS explaining why it can't yet be
  derived from the Draft (no reverse "keywords for this type" lookup).
- Assert getter/setter type hints and annotations on the standalone
  object-describing property test, plus JSON-array pass-through coverage now
  that the array/object type-guard fix (PR #170) is in.
- Move template-authoring rationale comments in ComposedItem.phptpl behind
  `{# #}` so they're stripped from generated code, matching the file's own
  existing convention.
- Replace string-concatenation-built regexes with heredocs across the
  composition tests touched by this branch.
- Trim implementation-process narration (phase numbers, "expected to pass
  already", planning-doc pointers) from Issue72Test docblocks in favor of
  describing the tested behavior itself.
Planning/analysis documents under .claude/ are working notes for the
session, not repository artifacts, and must not land on master.
generation-time warning, and sweep composition-implied-object consumers

P3.4:
- assertNoObjectScalarTypeConflict() previously flagged ANY branch with a
  nested schema as object-asserting for allOf conflict purposes, but a
  guarded (object-describing) branch also gets a nested schema and is
  vacuously satisfied by non-objects - so allOf: [{properties, required},
  {type: string}] was wrongly rejected at generation time as unsatisfiable,
  even though a string legitimately satisfies both branches. Only flag
  branches that genuinely assert object-ness (nested schema AND a real
  'object' type check, the signal wireObjectProperty()/
  wireDescribingObjectProperty() already leave behind).
- Add the planned generation-time warning for object-describing properties/
  branches ("does not constrain non-object values"), scoped once at
  PropertyFactory's single ObjectDescribing routing site so it covers both
  standalone properties and every composition branch.

P3.5 consumer sweep (root-level allOf of implied-object $ref definitions,
schema dependencies): required-promotion, property transfer, and runtime
enforcement all verified working for the general re-routing path. Found and
documented (not fixed - needs its own investigation) a real gap: a schema
dependency whose value is a MULTI-LEVEL composition-implied object silently
drops its target's validators when PropertyFactory::processBaseReference()
transfers properties across the class boundary, while the single-level case
works correctly. Pinned as an expected-failing red test per the "never
narrow test scope to evade failures" convention, with the root cause traced
and documented in the test's own docblock.
…d object

PropertyFactory::processBaseReference() transferred a referenced schema's
properties onto the referencing class but not its base validators. A
composition-implied-object $ref target (e.g. an allOf of further $refs)
enforces requiredness and cross-branch constraints via its own composition
validator, not via validators on the individual properties - those are
merged/redirected and carry no validation of their own, same as
transferComposedPropertiesToSchema() already documents for the case where
the composition sits directly on the class instead of behind a $ref.

Fixes two symptoms of the same gap: a schema `dependencies` value pointing
to a multi-level implied object silently accepted payloads that violated
it, and a schema file whose entire top level is `{"$ref": ...}` to one had
the same problem (the latter was flagged as an untested probable symptom
during the original Phase 3 analysis).

Flips testDependencyWithMultiLevelImpliedObjectEnforcesConstraints from red
to green with no test changes, per the "never narrow test scope" rule; adds
testRootLevelReferenceToMultiLevelImpliedObjectInstantiatesAndValidates for
the second symptom.
claude added 7 commits August 1, 2026 00:17
- Fix documentation that claimed setImplicitObjectComposition(true) causes
  non-object input to be "silently accepted": the generated constructor is
  always array-typed, so createBaseProperty() behaves byte-for-byte the same
  as an explicit type: object schema either way - there is no such pathway.
- Derive ObjectShapeResolver's object-describing keyword set from the Draft's
  own registered validators instead of a hardcoded list, now that 'required'
  is properly registered (#172). 'dependencies' stays an explicit addition -
  it is read as a side channel by other factories rather than its own
  addValidator() entry, mirroring AbstractCompositionValidatorFactory's
  MODIFIER_ONLY_VALIDATION_KEYWORDS pattern for the same class of gap.
- Remove PropertyFactory::resolveObjectShape(), a one-line wrapper adding
  nothing beyond its two call sites now that they also need to pass a Draft.
- Shorten SchemaProcessor::checkObjectRepresentability()'s docblock to the
  non-obvious part only.
- Cover both configs in testExplicitObjectTypeAtRootAcceptsDescribingBranchesRegardlessOfConfig,
  which previously only exercised the default one despite its name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kJ3xjHFogftRV4m2ukJFN
…ropertiesToSchema

Repurposing RootLevelOneOfWithVacuousBranch.json's test to assert the new
checkObjectRepresentability() rejection (untyped root, ambiguous composition)
left the "neither a nested schema nor an explicit type" branch in
transferComposedPropertiesToSchema() uncovered - that schema now gets
intercepted before generation ever reaches it. Confirmed via marker
instrumentation: 0 hits across the full suite before this fix.

Add RootLevelOneOfWithVacuousBranchAndExplicitType.json: the same
oneOf: [<object branch>, true] shape, but with an explicit "type": "object"
on the root, which short-circuits checkObjectRepresentability() to
ObjectAsserting while the literal `true` branch (never type-injected by
inheritPropertyType()) stays genuinely vacuous - reproducing the original
double-match validation behavior and exercising the target code path again.

Also fixes a stale docblock referencing the resolveObjectShape() method
removed in the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kJ3xjHFogftRV4m2ukJFN
The branch-error recording added for direct-exception mode only appended an
ErrorRegistryException for failing branches, while
InvalidComposedValueException derives the element number from the array
position. The printed index was therefore the position among failures, not the
branch's real position in the schema: an allOf whose second branch fails
reported it as "Composition element #1", and with three branches failing at
positions 1 and 3 they were reported as #1 and #2.

Mirror the collect-errors path instead: a branch that runs to completion inside
the per-branch try block appends an empty ErrorRegistryException, which renders
as "Valid". Every branch now contributes exactly one entry in schema order, so
both modes produce identically shaped, correctly numbered messages.

Expectations that encoded the old numbering are corrected - most notably the
nested-oneOf case in Issue167Test, where the passing anyOf branch was omitted
and the failing oneOf branch was labelled #1 instead of #2. A regression test
pins a three-branch allOf whose middle branch passes.

Also resolve two documentation defects: the class-defining-compositions section
contradicted its own note about named properties, array items and dependency
targets, and the object-describing warning was described as covering every such
property or branch when it is only emitted for a bare one - not for a schema
reached through a $ref or wrapped in a composition keyword.

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

Four review findings on the composition changes, none of which had test
coverage proving or disproving them.

The nested-schema condition added to withoutNestedCompositionValidation() is
unreachable. Its only caller runs on a generated class's own base property,
which is always forced to type: object, so every untyped branch inherits that
type and is routed into its own generated class - and such a branch never
carries a composed validator of its own. A branch declaring an explicit
non-object type instead is rejected earlier by the "No nested schema for
composed property" check. Eight schema shapes were generated with and without
the condition and produced byte-identical results, so master's unconditional
filter is restored with a comment recording the invariant.

The is_array() guard on the branch-default reset is reachable, but not for the
reason its comment implied: for a base validator the value is always an array.
It fires for a named-property composition whose satisfied branch is a scalar,
when an unrelated sibling property of the outer schema happens to share a name
with a property of one of the branches - setupBranchDefaultHelpers() matches by
name, not identity, so the branch-default map is populated and array_key_exists
is handed the scalar. A regression test pins exactly that collision.

The base-validator transfer in processBaseReference() restores considerably
more than the composition case it was written for: additionalProperties,
minProperties and maxProperties reached through a base-level $ref were silently
dropped, and a referenced root composition never ran at all - the class
constructed successfully and only failed later as a TypeError from the typed
getter. Both are now covered.

Finally, generated class names in composition messages are no longer normalised
away before assertion. They stay as they are - the name is the pre-existing
convention for base-level compositions and cannot be replaced by the property
name, since one generated class is shared by every property with an identical
composition - but the assertions now pin the complete message with only the
per-run uniqid left as a pattern. Tightening them surfaced two masked
differences: an if/then/else test asserted one message for two variants that
produce different class names, and an array-item data provider shared one
expected message across both error modes although only error collection emits
the follow-up type error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
Three related corrections to checkObjectRepresentability() and its coverage.

A schema carrying a filter is classified Blocking by ObjectShapeResolver
because it belongs to the filter-composition subsystem, not the object path.
At a class-defining root that surfaced as "does not resolve to a definite
object", masking the filter subsystem's own accurate diagnostic - a root
{"filter": "trim", "allOf": [...]} reported the object verdict instead of
"Filter trim is not compatible with property type base". Such schemas are now
skipped exactly like $ref schemas, for the same reason, and a test pins that
the filter message is what comes out.

The rejection message now points at setImplicitObjectComposition() - but only
when the resolved shape is ObjectDescribing. The flag widens acceptance from
ObjectAsserting to ObjectAsserting|ObjectDescribing, so it can rescue a
describing composition and never a NotObject one; suggesting it for the latter
would send the user after an option that cannot help.

Assertions on that message were anchored, so the message form is pinned end to
end: which of the two variants a schema produces, and the trailing source
position. They previously stopped at "generated class", which is what let the
two forms diverge unnoticed. The cross-file $ref target also gained the
acceptance counterpart it was missing - it is one of only two sites where the
flag has any effect today, and only the rejection half was covered. It needs
its own fixture: a bare $ref root cannot demonstrate it, because
processBaseReference() independently requires the referenced schema to provide
an object definition, so the flag never gets to speak.

Finally, four classification paths in ObjectShapeResolver gained cases: a $ref
to a boolean schema (both polarities, alone and beside an asserting sibling,
which is where Neutral and Blocking actually diverge), a non-string $ref value,
then/else given as $refs, and not beside sibling object keywords.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
Each generateClassFromFile call is a full code-generation pass and the dominant
cost in the suite, so assertions that can share one generated class belong in
one test method. This file ran 54 generation passes across 39 call sites where
30 cover the same behaviour: accept and reject halves of the same schema were
split across method pairs, and several data providers regenerated one schema
per input value rather than per schema.

Providers that genuinely cross a schema dimension with a value dimension keep
their provider - the schema stays the provider's dimension and the values are
looped inside, so each schema is generated once instead of once per pair.
StandaloneObjectDescribingProperty's three tests stay separate: they pass three
different generator configurations and cannot share a class.

No scenario or assertion was dropped - every input value and expected message
fragment is still exercised, and no fixture was orphaned. File runtime halves,
from 0.57s to 0.28s.

Also drops a stale docblock claim that the non-object case of a bare-validator
anyOf was deliberately uncovered pending a design decision. It is covered, and
the decision - accept per strict spec, since the bare branches are vacuously
satisfied - is recorded in the merged docblock.

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

PropertyFactory::create() built an ObjectShapeResolver separately in each of
its two reroute conditions, rebuilding the draft's object-keyword set both
times. The shared precondition is now tested once and the resolver memoized
behind a closure, so it is built at most once per call and still only when one
of the guards actually needs it - building it eagerly would pessimise the
majority of properties, which short-circuit on the cheap checks.

ObjectShapeResolver's docblock advertised that the keyword set adapts to custom
Drafts, which is true of everything it derives from the Draft's object Type but
not of UNREGISTERED_OBJECT_DESCRIBING_KEYWORDS, which hardcodes the Draft 7
'dependencies' keyword. There is nothing at Draft level to derive that from -
the keyword is read through a side channel with no addValidator registration -
so the wording now states the exception and what a Draft using
dependentSchemas/dependentRequired would need instead, rather than implying a
generality that is not there.

A third change was attempted and reverted: making a $ref to a boolean
definition classify like the equivalent inline boolean. The classification was
fixable within the resolver, but faithfully reporting a $ref to true as neutral
lets generation continue past the representability check and fail deeper with
an uncaught TypeError, because JsonSchema cannot carry a boolean. Bailing out
during classification keeps the failure a clean SchemaException, so the
conservative behaviour is kept and the reason recorded where the bail-out
happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
@wol-soft wol-soft changed the title Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones (fixes #72, #167) Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones (fixes #72) Aug 2, 2026
claude added 5 commits August 2, 2026 13:36
Three changes in this release can affect schemas and consumers that worked
before, so they get one migration section rather than being scattered across
the reference docs.

A class-defining composition that only describes object shape is now rejected -
which hits the common OpenAPI inheritance shape whenever the base schema omits
type: object, and a schema root carrying properties alongside an if/then with
no else. Both examples are shown with the two ways to fix them: declaring
type: object, or enabling implicit object composition.

Composition failures in direct-exception mode now enumerate every branch rather
than printing a bare header, shown as a before/after pair, since anyone matching
on that text will see the extra lines.

A base-level $ref now enforces the referenced schema's own object-level
constraints, which were previously dropped - a bug fix, but one that can reject
input that used to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
A schema file whose entire top level is {"$ref": "..."} diverged from the same
schema written inline, in two ways.

A reference to a root-level anyOf/oneOf was rejected outright with "must
provide an object definition", even when every branch declared type: object and
the composition was perfectly representable. The guard required a single nested
schema, but a disjunctive composition deliberately has none - its object values
are represented by branch-owned classes reachable through the composed
validator, as getNestedSchema()'s own contract states. Such a reference now
transfers its composition through transferComposedPropertiesToSchema(), the
same mechanism used when that composition sits directly on the class, so the
generated class enforces real oneOf/anyOf semantics: a $ref'ed oneOf now
rejects both zero matches and two. The guard is narrowed to what it was
actually protecting against - a reference to a schema that is neither an object
nor a composition, such as a scalar or array - and still fires there.

Separately, a reference to a target rejected by the representability check
reported "Unresolved Reference", blaming the referencing file for a problem in
the referenced one. Resolving a reference eagerly generates the target's class,
so a failure inside that generation was reaching the same catch block as a
genuine resolution failure and being rewrapped. Exceptions raised while
generating a referenced schema are now marked as complete diagnostics and pass
through unchanged, while missing files and malformed JSON still produce the
generic message.

Parity is asserted rather than assumed: the inline and $ref forms of the same
composition run through one shared assertion helper covering both accepted
branches and both rejection modes, so the two must behave identically and not
merely both generate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
The marker that keeps a target-side diagnostic from being restated as
"Unresolved Reference" was named after the action a caller must avoid rather
than the fact it records, which left the call sites explaining themselves. It
now says which schema failed - the referenced one, not the reference - which is
exactly the distinction the catch block needs, and the docblock carries the
reasoning instead of the name having to imply it.

Behaviour is unchanged; the flag is still default-false and additive on a class
users only ever catch.

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

A branch that constrains nothing can be written four ways - the literal true, an
empty {}, a metadata-only schema such as an example, or an annotation-only one.
All four accept every value, and all four already behaved identically at
runtime. Only three of them warned: the true arm of the branch loop built its
property and continued before the vacuity check ran, so the most explicit
spelling of "matches anything" was the one spelling that said nothing - and it
is the shape that crashed generation before this work.

createAlwaysTrueBranchProperty() already models the branch as an empty schema,
byte-identical to the empty-{} case, so it is routed through the same check
rather than given a parallel warning of its own; the two spellings are now
provably identical rather than accidentally similar. A false branch is
deliberately untouched: it is the opposite of vacuous and has its own
always-unsatisfiable diagnostic.

This exposes, rather than introduces, a second gap. The vacuity check reads the
branch JSON after inheritPropertyType() has run, and that step injects the outer
schema's type into any untyped branch - so whenever the outer schema declares a
type, an empty branch is seen as carrying a type and stops warning, while a true
branch keeps warning because the injection skips booleans. The suppressed
spellings are the wrong ones; making the check read what the author wrote means
removing the injection, which is tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
The section covered the constraints a base-level $ref previously dropped, but
not the two other outcomes of the same fix: a reference to a root anyOf/oneOf
now generates instead of being refused, and a reference to a schema that cannot
be generated now reports that schema's own error rather than a generic
unresolved-reference message. Neither breaks working schemas, but both change
what a user sees, and the second changes text that code may match on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
@wol-soft
wol-soft force-pushed the claude/mr-74-review-adoption-3p8hdm branch from c72088f to fa7729c Compare August 2, 2026 20:14
claude added 3 commits August 2, 2026 20:42
Wiring a guarded (object-describing) property ran the full ObjectModifier and
then removed two of the four things it had just done: the InstanceOfValidator
that rejects non-objects, and the representation-class type. Building state only
to strip it again hid the actual rule behind two undo steps, and left the reader
to work out that the remaining two effects were the intended ones.

ObjectModifier now takes an asserting flag. The instantiation linkage and
namespace registration always apply; the type and the instanceof check are the
assertion, and a describing schema simply never asks for them. The flag defaults
to true, so the Draft registry and every asserting caller are untouched.

Generated output is byte-identical for describing properties, describing array
items, asserting properties and re-routed allOf compositions.

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

The docs made three claims that were not true.

They opened by promising that a schema implying object-ness is treated like an
explicit type: object. That holds only for the asserting case. A describing
schema is recognized as object-shaped but still lets a non-object pass through,
and at a site that defines its own class it is rejected outright unless the
implicit-composition option is set. The opening now separates the two.

They claimed the describing warning is skipped for a schema reached through a
$ref or wrapped in a composition. Measured across every position such a schema
can occupy, the warning always fires - each branch and each reference target is
processed as a property in its own right and warns on its own account. The
exception text is gone.

They stated the definite-object requirement applies to named properties, array
items and dependency targets, then called itself stricter than the rule those
sites follow. Both cannot hold: those are nested values that may legitimately be
non-objects. The requirement is now scoped to schemas that become a class in
their own right, which is what it has always enforced. The same overstatement is
corrected in the option's own docblock and in the getting-started section.

Also removed explanations written in terms of internal processing order, and
dropped the migration section - release notes are composed separately once the
1.0 feature set is complete.

On the source side, deciding whether allOf branches contradict each other was
split across two methods that were reachable only in mutually exclusive
situations, because the type-transfer path returns early exactly where the
second check was needed. Conflict detection now has a single entry point,
invoked before that early return, with the shared intersection logic extracted
so the check and the transfer no longer carry separate copies. Rejected schemas
and messages are unchanged.

The rejection message now names declaring the type before the escape-hatch
option, since it is the better fix. PropertyFactory::create()'s two reroutes
moved into named methods, and the one-line describing-wire helper is inlined at
its only call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
Nine test methods were the same three lines apiece - expect a SchemaException
matching this pattern, generate this schema - differing only in the fixture and
the expected text. Collapsed into three provider-driven tests: root compositions
that cannot back a class, cross-file reference targets that cannot, and allOf
branches that contradict each other. The expectations now sit side by side,
which is where the differences between them are worth seeing.

The representability message has two variants that share a prefix, and the
shorter one is a prefix of the longer. A pattern stopping at "generated class"
therefore matches both, so each row now declares which variant it expects and
the shared builder anchors at both ends. Writing the rows out this way caught
that the describing fixture is only rejected under the default configuration -
enabling the option is precisely what accepts it - which the previous per-method
form had no reason to make visible.

The two config rows the provider needs were duplicated verbatim under two names
in one test class; they are now one provider on the shared test case, since any
behaviour that must hold regardless of the option needs them.

Reasoning that lived in the method docblocks moved onto the rows it explains.
Fixture usage is unchanged and no generation pass was added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDWcmSN3pSLa1azYemNXAh
@wol-soft wol-soft changed the title Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones (fixes #72) Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones Aug 3, 2026
claude added 4 commits August 3, 2026 08:30
checkObjectRepresentability() runs before any property processing, and
ObjectShapeResolver collapsed two different verdicts into Blocking: "this
is decidably not an object" and "object-ness cannot be determined here".
Undecidable components therefore terminated generation with a message
naming the wrong cause and offering no fix, replacing three precise
diagnostics the pipeline used to produce:

- an unresolvable $ref inside a root composition
- a filter inside a root composition branch
- a $ref carrying siblings, whose target cannot be resolved

The last of these also showed the documented $ref exemption covers only
bare $ref roots: JsonSchema's constructor rewrites {$ref, siblings} into
an allOf, so there is no top-level $ref key left to exempt.

Adds BranchObjectShape::Undecidable and ObjectShape::Undecidable.
Undecidable outranks Blocking in both combinators, since no definite
verdict can be derived from an unknown component, and the subsystem that
owns it reports precisely moments later. checkObjectRepresentability()
returns without throwing for it, which also makes the filter early return
redundant - and that return was too narrow anyway, never covering a
filter nested in a branch.

A $ref to a boolean definition stays decidably Blocking: JsonSchema::$json
is typed array, so the target is known and unrepresentable. Routing it
through Undecidable would hand it back to the pipeline and resurface the
uncaught TypeError this branch had already turned into a clean
SchemaException.

Also pins the branch enumeration gap for mutable base validators, which
is documented as a known limitation rather than fixed here.
…flicts

Three unrelated fixes in the composition factory.

Branch indices were emitted straight from the raw array key, so both the
vacuous-branch warning and the filter-in-branch SchemaException numbered
branches from zero while every runtime message numbers them from one
("Composition element #1"). Both sites now agree with the runtime
numbering. This changes an existing user-facing message.

A root schema always has type: object forced onto it, and every untyped
composition branch inherits that type. A branch whose enum lists only
non-object values becomes unsatisfiable, so the generated class rejects
every possible input with no diagnostic anywhere. Removing the inheritance
is the real fix and is tracked separately; until then the injection site
warns when it forces object onto a branch whose enum/const contains no
object value. Deliberately a warning, not a SchemaException: the
unsatisfiability is manufactured by the generator's own mutation step, so
rejecting would refuse schemas that become merely odd once it is removed.

setNestedSchema()'s docblock claimed a nested schema may only be set when
the value can exclusively be an object. A guarded object-describing
property violates that, which is precisely why the allOf conflict check
tests the nested schema and the type together. The docblock now describes
that two-signal reality.

Also renames the single-character closure parameters in this file.
The allOf/anyOf/oneOf notes claimed a true branch produces "the same
warning an equivalent empty {} branch would produce". That is false in the
common case: an empty branch inherits the enclosing schema's type, which
counts as a constraint and suppresses the warning, while a boolean branch
is not subject to the inheritance. Since a schema root always has
type: object applied, an empty branch in a root composition never warns.
The claim is removed and the actual rule stated once, in allOf.

A root carrying only object keywords, with no composition keyword, no $ref
and no type, is skipped before the representability check runs: no class,
no message, under either setting of setImplicitObjectComposition(). That
boundary is now stated in the flag's docblock and in the implied-objects
guide, so the silent skip is documented rather than surprising.
Master replaced JsonSchema's allOf rewrite hack for {$ref, siblings} with a
draft-aware producer model, and moved the reference resolution out of
PropertyFactory into RefResolver. Three things had to be adopted rather
than merged mechanically.

The object-shape classifier merged $ref siblings unconditionally, on the
assumption - true before, false now - that the constructor rewrite made
sibling handling draft-independent. Draft 07 registers $ref as an
ExclusiveProducer and ignores every sibling, so merging them classified
`allOf: [{$ref: <object>, type: string}]` as unsatisfiable and rejected a
schema master generates without complaint. classifyReference() now reads
the draft's own $ref producer and follows it, instead of restating the
policy.

The #182/#183 fixes lived in PropertyFactory::processBaseReference(),
which master rewrote from the pre-fix base into RefResolver. Re-applied
there on top of master's structure, so a base-level $ref still transfers
the referenced schema's base validators and still resolves to a
composition rather than being refused outright. The referenced-schema
failure marker likewise had to be re-wired into RefResolver, which
otherwise rewraps a target-side failure as "Unresolved Reference".

createObjectProperty()'s guarded mode was lost when master's version was
taken wholesale; restored.

checkObjectRepresentability()'s $ref exemption now covers sibling-bearing
roots too, since the rewrite that used to strip that key is gone. That is
left as-is deliberately: whether siblings apply is the draft's decision,
and the reference resolution that follows already implements it.

Suite matches master's baseline exactly - the 8 remaining errors
(ArrayContainsTest 2020-12, Issue186Test, AdditionalPropertiesAccessor)
reproduce on pristine master and are not from this branch.
Four crashes and false rejections, each verified against master to separate
regressions from pre-existing behaviour.

An allOf branch whose declared type is a LIST containing "object" was rejected
as conflicting with an object-asserting sibling. assertNoObjectScalarTypeConflict()
inferred "scalar branch" from the absence of a nested schema, but a multi-type
branch has none either: createMultiTypeProperty() puts it on the object
sub-property it builds, which is unreachable from the branch. That rejected
`allOf: [<object>, {"type": ["object", "null"]}]` - the ordinary "referenced
type, but nullable" shape - as unsatisfiable. The check now reads the branch's
declared type. Deliberately not routed through ObjectShapeResolver: that answers
whether a branch ASSERTS object-ness, which a multi-type branch does not, while
the question here is the weaker "can an object satisfy this branch at all".

Two cross-file reference shapes broke. checkObjectRepresentability() ran before
generateModel() registered the schema, and its $ref peek re-enters
processTopLevelSchema() for cross-file targets, so parseExternalFile()'s dedup
short-circuit could not fire: two files referencing each other recursed until the
stack was exhausted, and a target referencing back built a second render job
("File X.php already exists"). Both registrations now happen before the check.
ObjectShapeResolver's own cycle guard cannot cover this - it is local to one
classify() call and cannot see re-entrancy through the SchemaProcessor.

A composition keyword given as a JSON object rather than an array made every
branch-numbering site compute $index + 1 on a string key, and a branch that is
not a schema was indexed into as an array. Both now raise a SchemaException
naming the offending keyword and branch. `not` and if/then/else crashed the same
way on a scalar branch - pre-existing rather than a regression, but hardened
alongside, since rejecting a malformed allOf branch cleanly while `not` still
fatals is a half-fix.

The representability message withheld its fix suggestion from NotObject schemas.
The flag is correctly not offered there (it only ever widens acceptance to
ObjectDescribing), but declaring the type does resolve them, so it is now named -
conditionally, because unlike a describing schema a NotObject one really does
accept non-object values, so declaring the type changes the accepted set rather
than stating what the schema already means.

Ten docblocks still named PropertyFactory::processBaseReference()/
processReference() and PropertiesValidatorFactory::addDependencyValidator() after
the #185 rework moved them; a filter docblock still described the Blocking
verdict that became Undecidable; PropertyFactory carried an unused Exception
import.

Documentation: mergedProperty.rst documented a _Merged_ class for an allOf
example, and allOf.rst and oneOf.rst both promised one. Established by generating
each shape and reading the output: anyOf is the only keyword that produces a
merged property - OneOfValidatorFactory never calls createMergedProperty() at
all, and an allOf of object branches is routed through the object path to a
regular nested class. The allOf-inline and oneOf claims were already stale before
this branch; the $ref-based allOf form is what it changed. impliedObjects.rst
also claimed every object-describing schema warns, which is untrue of a
dependencies value - that site force-asserts silently.

New coverage for each fix, plus the bare object-describing array item this branch
introduced and the dependencies site's asymmetry, which was recorded nowhere
before. Two further shapes found while probing are filed as issues rather than
pinned as tests (#189, and a comment on #181): a test asserting a TypeError
documents a bug as if it were a contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wol-soft
wol-soft merged commit 45f630e into master Aug 12, 2026
7 checks passed
@wol-soft
wol-soft deleted the claude/mr-74-review-adoption-3p8hdm branch August 12, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants