Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones - #166
Merged
Merged
Conversation
Closed
Coverage Report for CI Build 31640503194Coverage increased (+0.07%) to 98.88%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
wol-soft
commented
Jul 21, 2026
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.
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.
- 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
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
force-pushed
the
claude/mr-74-review-adoption-3p8hdm
branch
from
August 2, 2026 20:14
c72088f to
fa7729c
Compare
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
This was referenced Aug 3, 2026
Closed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Gives the generator a principled way to decide whether a schema without an explicit
type: objectis, statically, an object — and to fail generation loudly instead of silentlymisbehaving 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
No nested schema for composed property, or the composed property was never instantiated (getCEO()returned a raw array despite a nested@return)$refdropped the referenced schema's object-level constraintsadditionalProperties,minProperties,propertyNamesand any root composition were never validated; an object could be constructed in an invalid state and only fail later with a PHPTypeErrorfrom a typed getter$refto a rootanyOf/oneOfwas always rejectedA referenced schema on base level must provide an object definition, even when every branch declaredtype: object. The same composition written inline worked — a$refdid not behave like the schema it points attruecomposition branch never warned{},example-only and annotation-only branches warned; the most explicit "matches anything" spelling stayed silentFixes #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 inwithoutNestedCompositionValidation(), and the representability check pre-empting three precisediagnostics (see Undecidable shapes below).
A later review pass on this branch found four more of the same kind, all fixed here:
allOfbranch declaring a type list containing"object"was rejected as conflicting withan 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.
$refshapes broke because the representability check ran before the schema wasregistered for deduplication, and its
$refpeek re-entersprocessTopLevelSchema(): two filesreferencing each other recursed until the stack was exhausted, and a target referencing back built
a duplicate render job (
File X.php already exists).TypeError: Unsupported operand types: string + intfrom the new 1-based branch numbering. It,and a branch that is not a schema at all, now raise a
SchemaExceptionnaming the keyword andbranch. (
notandif/then/elsecrashed the same way on a scalar branch — pre-existing, buthardened alongside.)
How it works
ObjectShapeandObjectShapeResolverstatically classify a schema by resolving$refchains andcomposition keywords. Wired into
PropertyFactory::create()so that:allOfwhose branches jointly guarantee object-ness is routed through the real object path —a generated class, instantiated, validated with
instanceof— even when no individual branchdeclares
type: object.typegets a guardedrepresentation: 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.
allOfbranches with contradictory types (an object-asserting branch alongside an explicitscalar-typed branch) are rejected at generation time instead of producing silently broken code.
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
$reftarget parsed as its own schema, produces.SchemaProcessor::checkObjectRepresentability()closes that gap the spec-compliant way: aclass-defining composition that does not resolve to
ObjectAssertingis rejected, unlessGeneratorConfiguration::setImplicitObjectComposition()is enabled (defaultfalse) as an opt-inescape hatch for object-describing compositions.
ObjectShapeResolverclassifiesif/then/elsefor this too — an aggregate asserts object-ness only when both
thenandelsedo.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 —
$refresolution, 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
$refto a boolean definition is deliberately notundecidable:
JsonSchema::$jsonis typedarray, so the target is known and unrepresentable, androuting it back to the pipeline would resurface the uncaught
TypeErrorthis branch replaced witha clean
SchemaException.$refsiblings follow the draftSince the
$ref-sibling rework landed on master, whether keywords beside a$refapply is decidedper draft: Draft 07 registers
$refas anExclusiveProducerand ignores them, Draft 2019-09 andlater apply them alongside the reference.
ObjectShapeResolver::classifyReference()reads thedraft's own
$refproducer and follows it rather than restating the policy — a classifier thatmerged siblings regardless would reject
allOf: [{$ref: <object>, "type": "string"}]under Draft 07,which the generator builds without complaint because the
typeis ignored there.checkObjectRepresentability()'s$refexemption 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 schemaroot carrying
propertiesplusif/thenwithoutelse. Fix by adding"type": "object", orby enabling
setImplicitObjectComposition(true). Two schemas in this repo's own test suiteneeded
"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 singlegenerated 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.{},true, annotation-only), which matches everyvalue.
{"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 wordedconditionally 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
itemsschema carrying only object-constraining keywords with notypenow gets aguarded 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
allOfof$refs to object schemas no longer produces a_Merged_class. Such a compositionguarantees an object, so it is routed through the object path and the property is typed with a
regular nested class instead (
X_Merged_Ceo→X_Ceo), which also changes the accessorsignatures from
mixedto that class. This makes the$refform behave like the inline form,which already generated a plain nested class.
anyOfis unaffected and remains the only keywordthat produces a merged property.
An
allOf-implied object property carrying an objectdefaultnow fails generation, consistentwith an explicit
type: objectproperty, 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 onisMutableBaseValidator. 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
SchemaExceptionis now 1-based, matching the runtimeComposition element #Nnumbering.Previously both counted from zero.
A base-level
$refnow enforces the referenced schema's object-level constraints (A base-level $ref silently drops the referenced schema's object-level constraints #182), soinput that was previously accepted may now be correctly rejected. Such a
$refto a rootanyOf/oneOfnow 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 thereferenced schema reports that schema's own error rather than
Unresolved Reference.A literal
truecomposition branch now emits the vacuous-branch warning (A literaltruecomposition 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:
$reftargets only; named properties, array items and
dependenciestargets bypass it. A root carryingonly object keywords with no composition keyword is skipped entirely — no class, no message —
under either setting of the flag.
inheritPropertyType()) is largely redundantonce representability is validated upfront, and actively corrupts a branch in two proven cases:
an
allOfbranch with a scalarenumbecomes 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.
$refto a boolean schema cannot be represented, becauseJsonSchemacannot carry a boolean.A reproduction of the named-property variant (which escapes as an uncaught
TypeError, unlike theroot variant's clean
SchemaException) is recorded in the Follow-up: broaden object-representability checks and reconsider forced type inheritance in compositions #181 thread.Filed separately, since it is independent of #181 and pre-existing on master:
#189 —
{"type": ["object"]}is classified as an object byObjectShapeResolverbut cannot be routed as one, sosuch a schema either fails with a misattributed
No nested schema for composed propertyor isskipped 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
7cff736(the$ref-sibling rework and the multi-draft infrastructure).phpcsclean on every file thisbranch touches. CI green on PHP 8.4 and 8.5.
ObjectShapeResolverTest,ComposedObjectShapeValidationTest,ComposedAllOfTest,Issue72Test,ReferencePropertyTest,ComposedOneOfBranchDefaultTest.$refspellings, the two cross-file recursion shapes, malformed composition keywords acrossallOf/anyOf/oneOf/not/then, the empty-composition roots, the bare object-describing arrayitem, and the
dependenciessite's force-asserting asymmetry (which was recorded nowhere before).TypeErroror a misattributed message documents a bug as if it were a contract.$refparity is asserted through a shared assertion helper covering both acceptedbranches and both rejection modes, so the two forms must behave identically rather than merely
both generating.
representability message, so a regression surfaces as a named failure.
$refsibling policy is pinned both as a unit matrix (Draft 07 vs 2019-09)and end to end.
docs/source/combinedSchemas/impliedObjects.rstanddocs/source/gettingStarted.rstfor theuser-facing documentation, including
setImplicitObjectComposition().mergedProperty.rst,allOf.rstandoneOf.rstwere corrected against generated output ratherthan from reading the code:
anyOfis the only keyword that produces a merged property(
OneOfValidatorFactorynever callscreateMergedProperty()at all), and the page's headlineallOfexample had documented a_Merged_class that is not generated. TheallOf-inline andoneOfclaims were already stale on master; only the$ref-basedallOfform changed here.