• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wol-soft / php-json-schema-model-generator / 31640503194

12 Aug 2026 09:00PM UTC coverage: 98.88% (+0.07%) from 98.811%
31640503194

Pull #166

github

wol-soft
Fix defects found while reviewing the object-shape work

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 rejec... (continued)
Pull Request #166: Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones

453 of 457 new or added lines in 15 files covered. (99.12%)

7947 of 8037 relevant lines covered (98.88%)

594.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.89
/src/PropertyProcessor/ObjectShape/ObjectShapeResolver.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\ObjectShape;
6

7
use Closure;
8
use PHPModelGenerator\Draft\Draft;
9
use PHPModelGenerator\Draft\Producer\ExclusiveProducer;
10
use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary;
11
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
12
use Throwable;
13
use TypeError;
14

15
/**
16
 * Statically classifies a raw decoded schema as ObjectAsserting, ObjectDescribing, NotObject, or
17
 * Undecidable (see ObjectShape for the semantics of each), resolving `$ref` chains through an
18
 * injected resolver callable so the classification works on definitions, external files, and
19
 * inline subschemas alike.
20
 *
21
 * The classification is deliberately conservative: a mixed-type composition that cannot assert
22
 * object-ness classifies as NotObject, and a component whose object-ness genuinely cannot be
23
 * established (unresolvable or cyclic references, schemas owned by other subsystems such as
24
 * transforming filters) classifies as Undecidable. Both keep the affected schema on its current
25
 * processing path via ObjectShapeResolver::resolve() (see BranchObjectShape::Blocking and
26
 * ::Undecidable respectively), but only Undecidable is exempted from
27
 * SchemaProcessor::checkObjectRepresentability()'s rejection, since a genuinely unknown shape
28
 * must be left to the subsystem that owns it to report precisely, rather than be reported here
29
 * as a confident "not an object" verdict.
30
 *
31
 * The set of object-describing keywords is derived from the injected Draft's own object Type
32
 * registrations (see the constructor), so a custom Draft that adds, removes, or renames
33
 * object-constraining keywords via addValidator()/addModifier() is picked up automatically -
34
 * with one documented exception: UNREGISTERED_OBJECT_DESCRIBING_KEYWORDS hardcodes 'dependencies'
35
 * for Draft 7, since that keyword is consumed through a side channel with no Draft-level
36
 * registration to derive it from. See that constant's docblock for what a custom Draft would
37
 * need to do differently.
38
 */
39
class ObjectShapeResolver
40
{
41
    /**
42
     * 'dependencies' constrains object values (a dependency on a property forces its target to
43
     * exist) but, unlike every other keyword below, is never registered on the Draft's object
44
     * Type via addValidator(): PropertiesValidatorFactory and RequiredValidatorFactory each read
45
     * $json['dependencies'][$propertyName] as a side channel instead (see
46
     * PropertyDependencyTrait), so it never appears in Type::getModifiers() and must be listed
47
     * explicitly - the same reason AbstractCompositionValidatorFactory::
48
     * MODIFIER_ONLY_VALIDATION_KEYWORDS lists keywords addModifier() hides from the registry.
49
     *
50
     * This list is Draft-7-specific, unlike the rest of $objectDescribingKeywords, which is
51
     * derived from the injected Draft and therefore adapts automatically to a custom Draft. A
52
     * Draft that splits 'dependencies' into 'dependentSchemas'/'dependentRequired' (as later
53
     * JSON Schema drafts do) - or that reads some other keyword through an equivalent side
54
     * channel invisible to Type::getModifiers() - would need its own entry here; this constant
55
     * is not derived from the Draft because PropertyDependencyTrait's side-channel reads have no
56
     * Draft-level registration to derive it from (see the class docblock on ObjectShapeResolver
57
     * for why a general mechanism was not built for this: no second Draft exists in this repo to
58
     * generalize against).
59
     */
60
    private const array UNREGISTERED_OBJECT_DESCRIBING_KEYWORDS = ['dependencies'];
61

62
    /** @var string[] every keyword whose presence (without a `type` declaration) makes a schema ObjectDescribing */
63
    private readonly array $objectDescribingKeywords;
64

65
    /**
66
     * Whether the injected Draft ignores keywords sitting next to `$ref` (Draft 07 and earlier)
67
     * rather than applying them alongside the reference (Draft 2019-09 and later).
68
     */
69
    private readonly bool $referenceSuppressesSiblings;
70

71
    /**
72
     * Composition keywords whose branches participate in shape aggregation via uniform per-branch
73
     * iteration. `not` is deliberately excluded: its subschema describes rejected values, not
74
     * accepted ones, so it structurally cannot assert a shape for the values a schema accepts.
75
     * `if`/`then`/`else` is also excluded from this array - not because it is unsupported, but
76
     * because it is a single triple rather than an array of branch schemas, so it does not fit
77
     * this array's uniform iteration and is classified separately by classifyIfThenElse().
78
     */
79
    private const array COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf'];
80

81
    /**
82
     * @param Draft                                    $draft       Used to derive the set of
83
     *                                                               object-describing keywords
84
     *                                                               from the object Type's own
85
     *                                                               registered validators.
86
     * @param Closure(string): (array|bool|null)|null $refResolver Resolves a `$ref` string to
87
     *                                                             the raw decoded JSON of its
88
     *                                                             target, or null when the
89
     *                                                             reference cannot be resolved.
90
     *                                                             Without a resolver every
91
     *                                                             `$ref`-bearing schema
92
     *                                                             classifies as Undecidable.
93
     */
94
    public function __construct(Draft $draft, private readonly ?Closure $refResolver = null)
2,951✔
95
    {
96
        $objectType = $draft->hasType('object') ? $draft->getTypes()['object'] : null;
2,951✔
97
        $registeredKeywords = $objectType !== null ? array_keys($objectType->getModifiers()) : [];
2,951✔
98

99
        $this->objectDescribingKeywords = array_merge(
2,951✔
100
            array_values(array_filter($registeredKeywords, 'is_string')),
2,951✔
101
            self::UNREGISTERED_OBJECT_DESCRIBING_KEYWORDS,
2,951✔
102
        );
2,951✔
103

104
        // Read the draft's own `$ref` producer rather than hardcoding a sibling policy, so the
105
        // classification always agrees with what the generator will actually do (see
106
        // classifyReference()). An ExclusiveProducer means the draft suppresses every keyword
107
        // sitting next to `$ref`.
108
        $this->referenceSuppressesSiblings =
2,951✔
109
            $draft->getProducerForKeyword('$ref') instanceof ExclusiveProducer;
2,951✔
110
    }
111

112
    /**
113
     * Build a resolver whose `$refResolver` peeks through `$ref` chains via the given schema
114
     * definition dictionary - a raw, un-processed peek, so no property is created for the target
115
     * and the classification stays side-effect-free for the common same-file reference case
116
     * (cross-file references may parse the external file, which the order-independent
117
     * external-schema machinery would parse moments later anyway).
118
     */
119
    public static function forDictionary(
2,878✔
120
        SchemaProcessor $schemaProcessor,
121
        SchemaDefinitionDictionary $dictionary,
122
        Draft $draft,
123
    ): self {
124
        $refResolver = static function (string $reference) use ($schemaProcessor, $dictionary): array|bool|null {
2,878✔
125
            $path = [];
56✔
126

127
            try {
128
                $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path);
56✔
129

130
                return $definition?->getSource()->navigate(implode('/', $path))->getJson();
56✔
131
            } catch (TypeError) {
4✔
132
                // A $ref that resolves to a boolean-valued definition (`true` or `false`) hits
133
                // JsonSchema::navigate(), which assigns the resolved leaf into a property typed
134
                // `array` and throws a TypeError for a boolean leaf. Unlike an unresolvable
135
                // reference, this is NOT undecidable - the target is known and is definitely not
136
                // representable as an object - so it must keep mapping to
137
                // BranchObjectShape::Blocking, not ::Undecidable. Returning false (rather than
138
                // null) achieves that: classify() already treats a literal `false` json value as
139
                // Blocking via its own is_bool() branch, so this reuses that path instead of
140
                // adding a second one. The true/false distinction of the actual definition is
141
                // deliberately not preserved (both collapse to `false` here): classifying `true`
142
                // faithfully as Neutral was tried and reverted, because it lets generation
143
                // proceed past checkObjectRepresentability() and then fail deeper in the pipeline
144
                // with this exact same uncaught TypeError once the real (non-speculative) $ref
145
                // resolution reaches JsonSchema::navigate() - trading a clean SchemaException for
146
                // an uncaught TypeError is a regression, not an improvement.
147
                return false;
2✔
148
            } catch (Throwable) {
2✔
149
                // An unresolvable, malformed, or cyclic reference leaves object-ness genuinely
150
                // undecidable - the target might turn out to be an object; returning null makes
151
                // the resolver bail out to BranchObjectShape::Undecidable, keeping the schema on
152
                // its current processing path and deferring to the real $ref resolution's own,
153
                // more precise error.
154
                return null;
2✔
155
            }
156
        };
2,878✔
157

158
        return new self($draft, $refResolver);
2,878✔
159
    }
160

161
    public function resolve(array|bool $json): ObjectShape
2,951✔
162
    {
163
        return match ($this->classify($json, [])) {
2,951✔
164
            BranchObjectShape::Asserting => ObjectShape::ObjectAsserting,
2,951✔
165
            BranchObjectShape::Describing => ObjectShape::ObjectDescribing,
489✔
166
            BranchObjectShape::Blocking, BranchObjectShape::Neutral => ObjectShape::NotObject,
461✔
167
            BranchObjectShape::Undecidable => ObjectShape::Undecidable,
2,951✔
168
        };
169
    }
170

171
    /**
172
     * Accepts `mixed` rather than `array|bool` because it is applied to raw, unvalidated schema
173
     * fragments: a composition branch, a `then`/`else` value or a `$ref` target can be any JSON
174
     * value in a malformed schema (e.g. `{"allOf": ["notASchema"]}`). Such a fragment is
175
     * Undecidable rather than a TypeError - the composition factory's own well-formedness check
176
     * (AbstractCompositionValidatorFactory::assertCompositionBranchesAreWellFormed()) rejects it
177
     * moments later with a message naming the actual defect, which a confident verdict from here
178
     * would preempt with an unrelated "does not resolve to a definite object".
179
     *
180
     * @param string[] $visitedReferences `$ref` strings on the current resolution path,
181
     *                                    used to bail out of reference cycles
182
     */
183
    private function classify(mixed $json, array $visitedReferences): BranchObjectShape
2,951✔
184
    {
185
        if (!is_array($json) && !is_bool($json)) {
2,951✔
NEW
186
            return BranchObjectShape::Undecidable;
×
187
        }
188

189
        if (is_bool($json)) {
2,951✔
190
            // true imposes nothing (neutral); false is unsatisfiable, so it must block a
191
            // sibling object branch from claiming a re-routable object assertion.
192
            return $json ? BranchObjectShape::Neutral : BranchObjectShape::Blocking;
28✔
193
        }
194

195
        // Filter-bearing schemas are owned by the filter-composition subsystem (input/output
196
        // type-space classification); classifying them as object-shaped would pull them out of
197
        // that machinery. This is genuinely undecidable rather than a "not an object" verdict -
198
        // the filter subsystem's own compatibility check runs moments later and reports a
199
        // precise, correctly-attributed error (e.g. naming the incompatible filter and property
200
        // type), which SchemaProcessor::checkObjectRepresentability() must not preempt with a
201
        // generic representability failure.
202
        if (array_key_exists('filter', $json)) {
2,949✔
203
            return BranchObjectShape::Undecidable;
5✔
204
        }
205

206
        if (array_key_exists('$ref', $json)) {
2,948✔
207
            return $this->classifyReference($json, $visitedReferences);
77✔
208
        }
209

210
        if (array_key_exists('type', $json)) {
2,942✔
211
            // A multi-type array asserts object-ness only when "object" is its sole listed
212
            // type - e.g. ["object"] is exactly equivalent to the bare "object" string. Any
213
            // other multi-type array (even ["object", "null"]) lets a non-object value satisfy
214
            // this branch, so it must not be treated as Asserting: a sibling allOf branch would
215
            // then be routed through the object path on the false assumption that every value
216
            // satisfying the composition is an object, silently mishandling the non-object
217
            // match (e.g. instantiating null as a nested class) instead of passing it through.
218
            return (array) $json['type'] === ['object'] ? BranchObjectShape::Asserting : BranchObjectShape::Blocking;
2,915✔
219
        }
220

221
        $componentShapes = [];
707✔
222

223
        foreach (self::COMPOSITION_KEYWORDS as $compositionKeyword) {
707✔
224
            if (!isset($json[$compositionKeyword]) || !is_array($json[$compositionKeyword])) {
707✔
225
                continue;
707✔
226
            }
227

228
            $branchShapes = array_map(
339✔
229
                fn(mixed $branchJson): BranchObjectShape => $this->classify($branchJson, $visitedReferences),
339✔
230
                $json[$compositionKeyword],
339✔
231
            );
339✔
232

233
            $componentShapes[] = $compositionKeyword === 'allOf'
339✔
234
                ? $this->combineConjunctive($branchShapes)
217✔
235
                : $this->combineDisjunctive($branchShapes);
129✔
236
        }
237

238
        if (isset($json['if'])) {
707✔
239
            $componentShapes[] = $this->classifyIfThenElse($json, $visitedReferences);
28✔
240
        }
241

242
        if (array_intersect(array_keys($json), $this->objectDescribingKeywords) !== []) {
707✔
243
            $componentShapes[] = BranchObjectShape::Describing;
52✔
244
        }
245

246
        // All constraints of a single schema object apply simultaneously, so multiple
247
        // components (e.g. describing keywords next to an allOf) combine conjunctively.
248
        return $componentShapes === [] ? BranchObjectShape::Neutral : $this->combineConjunctive($componentShapes);
707✔
249
    }
250

251
    /**
252
     * Classify an if/then/else conditional. Every instance takes exactly one of two paths -
253
     * satisfies `if` (then `then` applies) or doesn't (then `else` applies) - so the aggregate
254
     * only asserts object-ness when BOTH paths do, exactly like combineDisjunctive()'s existing
255
     * "every branch must assert" rule for anyOf/oneOf applied to these two branches. A missing
256
     * `then` or `else` imposes no constraint on the instances routed through it (same as a
257
     * boolean `true` schema), so it classifies as Neutral, which - via combineDisjunctive -
258
     * degrades the aggregate below Asserting rather than blocking it outright: `if` without full
259
     * then/else coverage does not guarantee object-ness, but it also does not contradict a
260
     * sibling component that does.
261
     */
262
    private function classifyIfThenElse(array $json, array $visitedReferences): BranchObjectShape
28✔
263
    {
264
        $thenShape = isset($json['then'])
28✔
265
            ? $this->classify($json['then'], $visitedReferences)
26✔
266
            : BranchObjectShape::Neutral;
2✔
267
        $elseShape = isset($json['else'])
28✔
268
            ? $this->classify($json['else'], $visitedReferences)
22✔
269
            : BranchObjectShape::Neutral;
6✔
270

271
        return $this->combineDisjunctive([$thenShape, $elseShape]);
28✔
272
    }
273

274
    private function classifyReference(array $json, array $visitedReferences): BranchObjectShape
77✔
275
    {
276
        $reference = $json['$ref'];
77✔
277

278
        // Unresolvable and cyclic references are undecidable, not decidably non-object: without
279
        // seeing the target, claiming object-ness (or even neutrality, which would let sibling
280
        // branches assert it) is unsound - a hidden scalar target would make the aggregate
281
        // unsatisfiable - but the target might just as well turn out to be an object. The real
282
        // $ref resolution that runs moments later reports a precise, correctly-attributed error
283
        // (e.g. naming the missing reference), which
284
        // SchemaProcessor::checkObjectRepresentability() must not preempt with a generic
285
        // representability failure.
286
        if (
287
            !is_string($reference)
77✔
288
            || $this->refResolver === null
75✔
289
            || in_array($reference, $visitedReferences, true)
77✔
290
        ) {
291
            return BranchObjectShape::Undecidable;
5✔
292
        }
293

294
        $targetJson = ($this->refResolver)($reference);
74✔
295

296
        if ($targetJson === null) {
74✔
297
            return BranchObjectShape::Undecidable;
4✔
298
        }
299

300
        $visitedReferences[] = $reference;
70✔
301
        $targetShape = $this->classify($targetJson, $visitedReferences);
70✔
302

303
        // Classification has to mirror how the generator will actually process the schema: a
304
        // classifier that disagreed would reject schemas the generator handles fine, or route ones
305
        // it cannot. Sibling handling is draft-dependent, so this follows the draft's own `$ref`
306
        // producer rather than reimplementing the policy.
307
        //
308
        // Draft 07 and earlier suppress every keyword next to `$ref` (an ExclusiveProducer), so
309
        // the target's shape IS the node's shape and siblings must not be merged. Merging them
310
        // anyway is not a harmlessly stricter reading: `allOf: [{$ref: <object>, type: string}]`
311
        // generates fine under Draft 07 because the `type` is ignored, and merging it conjunctively
312
        // would classify the branch Blocking and reject a schema the generator supports.
313
        //
314
        // Draft 2019-09 and later apply siblings alongside the reference, so they genuinely
315
        // constrain the same value and combine conjunctively here.
316
        if ($this->referenceSuppressesSiblings) {
70✔
317
            return $targetShape;
25✔
318
        }
319

320
        $siblingJson = array_diff_key($json, ['$ref' => null]);
45✔
321
        $siblingShape = $this->classify($siblingJson, $visitedReferences);
45✔
322

323
        return $this->combineConjunctive([$targetShape, $siblingShape]);
45✔
324
    }
325

326
    /**
327
     * Combine shapes that must all hold for the same value (allOf branches, or the components
328
     * of one schema object): one unsatisfiable-with-object component poisons the aggregate;
329
     * otherwise a single asserting component makes the whole aggregate object-asserting.
330
     *
331
     * Undecidable is checked BEFORE Blocking, even though a Blocking component alone would
332
     * already be enough to fail the composition. A definite Blocking verdict cannot be derived
333
     * from an aggregate that also contains a component whose own shape is unknown - the unknown
334
     * component might resolve to something that changes the picture, and more importantly the
335
     * subsystem that owns it ($ref resolution, the filter machinery) reports its own precise,
336
     * correctly-attributed error moments later. Checking Blocking first was considered and
337
     * rejected: it would let e.g. `allOf: [{type: string}, {$ref: '#/definitions/missing'}]`
338
     * report the generic "does not resolve to a definite object" message from
339
     * SchemaProcessor::checkObjectRepresentability() instead of the unresolved-reference error
340
     * the real $ref resolution produces for the same schema.
341
     *
342
     * @param BranchObjectShape[] $shapes
343
     */
344
    private function combineConjunctive(array $shapes): BranchObjectShape
371✔
345
    {
346
        return match (true) {
347
            in_array(BranchObjectShape::Undecidable, $shapes, true) => BranchObjectShape::Undecidable,
371✔
348
            in_array(BranchObjectShape::Blocking, $shapes, true) => BranchObjectShape::Blocking,
363✔
349
            in_array(BranchObjectShape::Asserting, $shapes, true) => BranchObjectShape::Asserting,
313✔
350
            in_array(BranchObjectShape::Describing, $shapes, true) => BranchObjectShape::Describing,
74✔
351
            default => BranchObjectShape::Neutral,
371✔
352
        };
353
    }
354

355
    /**
356
     * Combine shapes of alternative branches (anyOf/oneOf): the aggregate only asserts
357
     * object-ness when EVERY branch does. A neutral branch matches everything and a describing
358
     * branch is vacuously satisfied by non-objects, so either degrades the aggregate below
359
     * Asserting. A scalar branch also yields Blocking, but for a different reason than in the
360
     * conjunctive case: `anyOf: [object, string]` is a perfectly satisfiable union, not a
361
     * conflict - it is simply not object-asserting. Returning Neutral instead was considered
362
     * and rejected: it would let an OUTER allOf containing such a mixed union claim
363
     * object-assertion (semantically sound - the sibling object branch narrows the union - but
364
     * it would route a cross-typed nested composition through the object path, which is
365
     * deliberately out of the conservative initial scope).
366
     *
367
     * Undecidable is checked BEFORE Blocking here too, for the same reason as in
368
     * combineConjunctive(): a branch whose own shape is unknown must not let a sibling Blocking
369
     * branch produce a confident aggregate verdict, since the subsystem that owns the unknown
370
     * branch reports its own precise error moments later.
371
     *
372
     * @param BranchObjectShape[] $shapes
373
     */
374
    private function combineDisjunctive(array $shapes): BranchObjectShape
157✔
375
    {
376
        return match (true) {
377
            $shapes === [] => BranchObjectShape::Neutral,
4✔
378
            in_array(BranchObjectShape::Undecidable, $shapes, true) => BranchObjectShape::Undecidable,
153✔
379
            in_array(BranchObjectShape::Blocking, $shapes, true) => BranchObjectShape::Blocking,
152✔
380
            in_array(BranchObjectShape::Neutral, $shapes, true) => BranchObjectShape::Neutral,
138✔
381
            in_array(BranchObjectShape::Describing, $shapes, true) => BranchObjectShape::Describing,
128✔
382
            default => BranchObjectShape::Asserting,
157✔
383
        };
384
    }
385
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc