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

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

30 Jul 2026 09:16PM UTC coverage: 98.587% (-0.1%) from 98.735%
30582776386

Pull #166

github

claude
Align composition-error assertions with master's summary-only direct mode

The merge with master resolved the ComposedItem template conflict in favor of
master's structure (the extracted ComposedItemBody template), which does not
enumerate cleanly-validated branches in direct-exception mode. This drops the
per-branch "Valid" enumeration that this branch had added for direct mode.

Realign the affected direct-mode assertions to the actual summary-only output
(failing branches are still enumerated with their underlying reason; passing
branches are no longer listed, and the remaining failing branches renumber
sequentially). Also correct the impliedObjects doc example, which showed the
now-removed enumeration. Verified against pristine master: this matches
master's direct-mode composition-error format exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
Pull Request #166: Add characterization tests for issue #72 / PR #74 composition defects

194 of 206 new or added lines in 8 files covered. (94.17%)

2 existing lines in 2 files now uncovered.

7326 of 7431 relevant lines covered (98.59%)

577.82 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\ObjectShape;
6

7
use Closure;
8

9
/**
10
 * Statically classifies a raw decoded schema as ObjectAsserting, ObjectDescribing, or
11
 * NotObject (see ObjectShape for the semantics of each), resolving `$ref` chains through an
12
 * injected resolver callable so the classification works on definitions, external files, and
13
 * inline subschemas alike.
14
 *
15
 * The classification is deliberately conservative: whenever object-ness cannot be established
16
 * with certainty (unresolvable or cyclic references, mixed-type compositions, schemas owned by
17
 * other subsystems such as transforming filters), the resolver falls back to NotObject, which
18
 * keeps the affected schema on its current processing path.
19
 */
20
class ObjectShapeResolver
21
{
22
    /**
23
     * Keywords that constrain object values. Their presence without a `type` declaration makes
24
     * a schema ObjectDescribing - constraining objects while remaining vacuously satisfied by
25
     * non-object values.
26
     *
27
     * TODO: derive this list from the Draft instead of hardcoding it, the same way
28
     * warnIfVacuousBranch() derives its "is this a real validation keyword" check from
29
     * Draft::getTypesForKeyword(). Not currently possible: Draft only exposes a per-keyword
30
     * lookup (which types register a given keyword), not the reverse (which keywords a given
31
     * type registers), so there is no way to enumerate "every keyword registered on the object
32
     * Type" without first knowing the full keyword set to probe.
33
     */
34
    private const array OBJECT_DESCRIBING_KEYWORDS = [
35
        'properties',
36
        'required',
37
        'patternProperties',
38
        'additionalProperties',
39
        'propertyNames',
40
        'minProperties',
41
        'maxProperties',
42
        'dependencies',
43
    ];
44

45
    /**
46
     * Composition keywords whose branches participate in shape aggregation. `not` and
47
     * `if`/`then`/`else` are deliberately excluded and treated as neutral: `not` never asserts
48
     * a shape for the accepted values, and a conditional only asserts object-ness when both
49
     * the then and the else branch do AND the condition covers all inputs - classifying that
50
     * correctly is not required for any current consumer, so the conservative neutral fallback
51
     * applies.
52
     */
53
    private const array COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf'];
54

55
    /**
56
     * @param Closure(string): (array|bool|null)|null $refResolver Resolves a `$ref` string to
57
     *                                                             the raw decoded JSON of its
58
     *                                                             target, or null when the
59
     *                                                             reference cannot be resolved.
60
     *                                                             Without a resolver every
61
     *                                                             `$ref`-bearing schema
62
     *                                                             classifies as NotObject.
63
     */
64
    public function __construct(private readonly ?Closure $refResolver = null)
545✔
65
    {
66
    }
545✔
67

68
    public function resolve(array|bool $json): ObjectShape
545✔
69
    {
70
        return match ($this->classify($json, [])) {
545✔
71
            BranchObjectShape::Asserting => ObjectShape::ObjectAsserting,
545✔
72
            BranchObjectShape::Describing => ObjectShape::ObjectDescribing,
445✔
73
            BranchObjectShape::Blocking, BranchObjectShape::Neutral => ObjectShape::NotObject,
545✔
74
        };
75
    }
76

77
    /**
78
     * @param string[] $visitedReferences `$ref` strings on the current resolution path,
79
     *                                    used to bail out of reference cycles
80
     */
81
    private function classify(array|bool $json, array $visitedReferences): BranchObjectShape
545✔
82
    {
83
        if (is_bool($json)) {
545✔
84
            // true imposes nothing (neutral); false is unsatisfiable, so it must block a
85
            // sibling object branch from claiming a re-routable object assertion.
86
            return $json ? BranchObjectShape::Neutral : BranchObjectShape::Blocking;
16✔
87
        }
88

89
        // Filter-bearing schemas are owned by the filter-composition subsystem (input/output
90
        // type-space classification); classifying them as object-shaped would pull them out of
91
        // that machinery, so they block conservatively.
92
        if (array_key_exists('filter', $json)) {
543✔
93
            return BranchObjectShape::Blocking;
1✔
94
        }
95

96
        if (array_key_exists('$ref', $json)) {
542✔
97
            return $this->classifyReference($json, $visitedReferences);
56✔
98
        }
99

100
        if (array_key_exists('type', $json)) {
540✔
101
            // A multi-type array asserts object-ness only when "object" is its sole listed
102
            // type - e.g. ["object"] is exactly equivalent to the bare "object" string. Any
103
            // other multi-type array (even ["object", "null"]) lets a non-object value satisfy
104
            // this branch, so it must not be treated as Asserting: a sibling allOf branch would
105
            // then be routed through the object path on the false assumption that every value
106
            // satisfying the composition is an object, silently mishandling the non-object
107
            // match (e.g. instantiating null as a nested class) instead of passing it through.
108
            return (array) $json['type'] === ['object'] ? BranchObjectShape::Asserting : BranchObjectShape::Blocking;
157✔
109
        }
110

111
        $componentShapes = [];
533✔
112

113
        foreach (self::COMPOSITION_KEYWORDS as $compositionKeyword) {
533✔
114
            if (!isset($json[$compositionKeyword]) || !is_array($json[$compositionKeyword])) {
533✔
115
                continue;
533✔
116
            }
117

118
            $branchShapes = array_map(
160✔
119
                fn(array|bool $branchJson): BranchObjectShape => $this->classify($branchJson, $visitedReferences),
160✔
120
                $json[$compositionKeyword],
160✔
121
            );
160✔
122

123
            $componentShapes[] = $compositionKeyword === 'allOf'
160✔
124
                ? $this->combineConjunctive($branchShapes)
154✔
125
                : $this->combineDisjunctive($branchShapes);
12✔
126
        }
127

128
        if (array_intersect(array_keys($json), self::OBJECT_DESCRIBING_KEYWORDS) !== []) {
533✔
129
            $componentShapes[] = BranchObjectShape::Describing;
30✔
130
        }
131

132
        // All constraints of a single schema object apply simultaneously, so multiple
133
        // components (e.g. describing keywords next to an allOf) combine conjunctively.
134
        return $componentShapes === [] ? BranchObjectShape::Neutral : $this->combineConjunctive($componentShapes);
533✔
135
    }
136

137
    private function classifyReference(array $json, array $visitedReferences): BranchObjectShape
56✔
138
    {
139
        $reference = $json['$ref'];
56✔
140

141
        // Unresolvable and cyclic references block: without seeing the target, claiming
142
        // object-ness (or even neutrality, which would let sibling branches assert it) is
143
        // unsound - a hidden scalar target would make the aggregate unsatisfiable.
144
        if (
145
            !is_string($reference)
56✔
146
            || $this->refResolver === null
56✔
147
            || in_array($reference, $visitedReferences, true)
56✔
148
        ) {
149
            return BranchObjectShape::Blocking;
2✔
150
        }
151

152
        $targetJson = ($this->refResolver)($reference);
55✔
153

154
        if ($targetJson === null) {
55✔
155
            return BranchObjectShape::Blocking;
2✔
156
        }
157

158
        $visitedReferences[] = $reference;
53✔
159
        $targetShape = $this->classify($targetJson, $visitedReferences);
53✔
160

161
        // Draft 7 ignores keywords next to $ref, but this generator deliberately merges them
162
        // (the JsonSchema constructor rewrites `{$ref, siblings}` into an allOf of both), so
163
        // the shape must reflect that merge: target and siblings combine conjunctively.
164
        $siblingJson = array_diff_key($json, ['$ref' => null]);
53✔
165
        $siblingShape = $this->classify($siblingJson, $visitedReferences);
53✔
166

167
        return $this->combineConjunctive([$targetShape, $siblingShape]);
53✔
168
    }
169

170
    /**
171
     * Combine shapes that must all hold for the same value (allOf branches, or the components
172
     * of one schema object): one unsatisfiable-with-object component poisons the aggregate;
173
     * otherwise a single asserting component makes the whole aggregate object-asserting.
174
     *
175
     * @param BranchObjectShape[] $shapes
176
     */
177
    private function combineConjunctive(array $shapes): BranchObjectShape
184✔
178
    {
179
        return match (true) {
180
            in_array(BranchObjectShape::Blocking, $shapes, true) => BranchObjectShape::Blocking,
184✔
181
            in_array(BranchObjectShape::Asserting, $shapes, true) => BranchObjectShape::Asserting,
142✔
182
            in_array(BranchObjectShape::Describing, $shapes, true) => BranchObjectShape::Describing,
41✔
183
            default => BranchObjectShape::Neutral,
184✔
184
        };
185
    }
186

187
    /**
188
     * Combine shapes of alternative branches (anyOf/oneOf): the aggregate only asserts
189
     * object-ness when EVERY branch does. A neutral branch matches everything and a describing
190
     * branch is vacuously satisfied by non-objects, so either degrades the aggregate below
191
     * Asserting. A scalar branch also yields Blocking, but for a different reason than in the
192
     * conjunctive case: `anyOf: [object, string]` is a perfectly satisfiable union, not a
193
     * conflict - it is simply not object-asserting. Returning Neutral instead was considered
194
     * and rejected: it would let an OUTER allOf containing such a mixed union claim
195
     * object-assertion (semantically sound - the sibling object branch narrows the union - but
196
     * it would route a cross-typed nested composition through the object path, which is
197
     * deliberately out of the conservative initial scope).
198
     *
199
     * @param BranchObjectShape[] $shapes
200
     */
201
    private function combineDisjunctive(array $shapes): BranchObjectShape
12✔
202
    {
203
        return match (true) {
NEW
204
            $shapes === [] => BranchObjectShape::Neutral,
×
205
            in_array(BranchObjectShape::Blocking, $shapes, true) => BranchObjectShape::Blocking,
12✔
206
            in_array(BranchObjectShape::Neutral, $shapes, true) => BranchObjectShape::Neutral,
5✔
207
            in_array(BranchObjectShape::Describing, $shapes, true) => BranchObjectShape::Describing,
4✔
208
            default => BranchObjectShape::Asserting,
12✔
209
        };
210
    }
211
}
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