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

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

20 May 2026 10:28AM UTC coverage: 98.466% (-0.09%) from 98.554%
26158351968

Pull #128

github

wol-soft
Fix if/then/else branch conflict detection for null-typed and per-branch checks

IfValidatorFactory previously required both then and else branches to conflict
with the parent before throwing, and stripped null from parent type names — so a
null-typed branch against a non-null parent was silently accepted. Now each branch
is checked independently and null from isNullable() is included in the parent set.
getBranchTypeNames() distinguishes null-typed branches ({type: null}) from truly
untyped branches via getTypeHint(), so null-typed branches correctly intersect (or
conflict) with the parent type. CLAUDE.md extended with review-learning policy and
line-number-in-docblock rule. New test coverage for all affected edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #128: Filter composition ordering

772 of 792 new or added lines in 22 files covered. (97.47%)

5 existing lines in 3 files now uncovered.

5455 of 5540 relevant lines covered (98.47%)

592.57 hits per line

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

97.83
/src/PropertyProcessor/Filter/CompositionBranchClassifier.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\Filter;
6

7
use PHPModelGenerator\Draft\Draft;
8
use PHPModelGenerator\Utils\TypeCheck;
9
use PHPModelGenerator\Utils\TypeConverter;
10

11
/**
12
 * Classifies a single composition branch as Input, Output, Mixed, or Empty with
13
 * respect to the type-spaces of a transforming filter T: InputTypes → OutputTypes.
14
 *
15
 * Classification rules:
16
 *   - Input  — every constraint targets the filter's input type-space.
17
 *   - Output — every constraint targets the filter's output type-space.
18
 *   - Mixed  — constraints span both type-spaces.
19
 *   - Empty  — the branch imposes no structural constraints (empty schema `{}`).
20
 *
21
 * Ambiguous keywords (registered only on the 'any' type, or not registered at all)
22
 * are treated as non-constraining and do not shift the branch away from a unanimous
23
 * single-space classification. When ALL keywords in a branch are ambiguous, the
24
 * liberal policy applies and the branch is classified as Input.
25
 */
26
class CompositionBranchClassifier
27
{
28
    /**
29
     * JSON Schema composition keywords that may contain nested branch schemas.
30
     * Each is classified recursively rather than via the Draft type registry.
31
     */
32
    private const array NESTED_COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'not'];
33

34
    /**
35
     * @param Draft    $draft       The active Draft instance used to resolve keyword type-spaces.
36
     * @param string[] $inputTypes  PHP type names accepted by the transforming filter
37
     *                              (e.g. ['string']).
38
     * @param string[] $outputTypes PHP type names returned by the transforming filter
39
     *                              (e.g. ['DateTime']).
40
     */
41
    public function __construct(
183✔
42
        private readonly Draft $draft,
43
        private readonly array $inputTypes,
44
        private readonly array $outputTypes,
45
    ) {}
183✔
46

47
    /**
48
     * Classify a single schema keyword by looking up which Draft-registered types carry it
49
     * and mapping those types onto the filter's input / output type-spaces.
50
     *
51
     * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') and the structural 'type' keyword
52
     * are not handled here — call classify() for full branch analysis.  Returns TypeSpace::Empty
53
     * for keywords registered only on the 'any' pseudo-type (e.g. 'enum', 'filter') or for
54
     * keywords not registered at all (e.g. '$schema', 'description').
55
     */
56
    public function classifySchemaKey(string $key): TypeSpace
2✔
57
    {
58
        $phpTypeNames = array_values(array_filter(
2✔
59
            array_map(
2✔
60
                static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType),
2✔
61
                $this->draft->getTypesForKeyword($key),
2✔
62
            ),
2✔
63
            static fn(string $type): bool => $type !== 'any',
2✔
64
        ));
2✔
65

66
        return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames);
2✔
67
    }
68

69
    /**
70
     * Classify a single composition branch schema.
71
     *
72
     * @param array<string, mixed> $branchSchema Decoded JSON object for one branch.
73
     */
74
    public function classify(array $branchSchema): TypeSpace
90✔
75
    {
76
        if (empty($branchSchema)) {
90✔
77
            return TypeSpace::Empty;
4✔
78
        }
79

80
        $hasInput  = false;
87✔
81
        $hasOutput = false;
87✔
82

83
        foreach ($branchSchema as $keyword => $value) {
87✔
84
            $contribution = $this->classifyKeyword($keyword, $value);
87✔
85

86
            if ($contribution === TypeSpace::Mixed) {
87✔
87
                return TypeSpace::Mixed;
2✔
88
            }
89

90
            match ($contribution) {
91
                TypeSpace::Input  => $hasInput  = true,
87✔
92
                TypeSpace::Output => $hasOutput = true,
49✔
93
                default           => null,  // Empty — no constraint, skip
18✔
94
            };
95
        }
96

97
        return match (true) {
98
            $hasInput && $hasOutput => TypeSpace::Mixed,
87✔
99
            $hasInput               => TypeSpace::Input,
81✔
100
            $hasOutput              => TypeSpace::Output,
43✔
101
            default                 => TypeSpace::Input,  // all ambiguous: liberal policy
87✔
102
        };
103
    }
104

105
    /**
106
     * Determine the type-space contribution of a single keyword within a branch.
107
     *
108
     * The 'type' keyword is always classified as Input: it asserts raw JSON value structure
109
     * and must validate against the unfiltered input regardless of the filter's output type.
110
     * Combining 'type' with output-targeted constraints (e.g. minimum/maximum) in the same
111
     * branch therefore produces TypeSpace::Mixed, which is correctly rejected as unresolvable.
112
     *
113
     * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') are classified recursively via
114
     * classifyNestedComposition() — call classify() for full branch analysis.
115
     *
116
     * Returns TypeSpace::Empty when the keyword carries no spatial information
117
     * (registered only on 'any', not registered at all, or an unrecognised key).
118
     */
119
    private function classifyKeyword(string $keyword, mixed $value): TypeSpace
87✔
120
    {
121
        if ($keyword === 'type') {
87✔
122
            return TypeSpace::Input;
31✔
123
        }
124

125
        if (in_array($keyword, self::NESTED_COMPOSITION_KEYWORDS, true)) {
77✔
126
            return $this->classifyNestedComposition($keyword, $value);
12✔
127
        }
128

129
        // Derive the spatial contribution from the Draft type registry.
130
        $draftTypeNames = $this->draft->getTypesForKeyword($keyword);
73✔
131

132
        if (empty($draftTypeNames)) {
73✔
133
            // Keyword not registered in any type (e.g. $schema, title, description).
134
            return TypeSpace::Empty;
4✔
135
        }
136

137
        // Convert JSON Schema type names to PHP type names and discard the 'any'
138
        // pseudo-type — it is not spatially specific (e.g. enum, const, filter).
139
        $phpTypeNames = array_values(array_filter(
69✔
140
            array_map(
69✔
141
                static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType),
69✔
142
                $draftTypeNames,
69✔
143
            ),
69✔
144
            static fn(string $type): bool => $type !== 'any',
69✔
145
        ));
69✔
146

147
        return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames);
69✔
148
    }
149

150
    /**
151
     * Classify nested composition keywords (allOf, anyOf, oneOf, not) by recursing
152
     * into their inner branch schemas.
153
     */
154
    private function classifyNestedComposition(string $keyword, mixed $value): TypeSpace
12✔
155
    {
156
        if ($keyword === 'not') {
12✔
157
            return !is_array($value) ? TypeSpace::Empty : $this->classify($value);
2✔
158
        }
159

160
        // allOf, anyOf, oneOf: value must be an array of branch schemas.
161
        if (!is_array($value)) {
10✔
NEW
162
            return TypeSpace::Empty;
×
163
        }
164

165
        $nonEmpty = array_values(array_filter(
10✔
166
            array_map(
10✔
167
                fn(mixed $branch): TypeSpace => is_array($branch)
10✔
168
                    ? $this->classify($branch)
10✔
169
                    : TypeSpace::Empty,
10✔
170
                $value,
10✔
171
            ),
10✔
172
            static fn(TypeSpace $space): bool => $space !== TypeSpace::Empty,
10✔
173
        ));
10✔
174

175
        if (empty($nonEmpty)) {
10✔
176
            return TypeSpace::Empty;
1✔
177
        }
178

179
        $hasMixed  = in_array(TypeSpace::Mixed, $nonEmpty, true);
9✔
180
        $hasInput  = in_array(TypeSpace::Input, $nonEmpty, true);
9✔
181
        $hasOutput = in_array(TypeSpace::Output, $nonEmpty, true);
9✔
182

183
        return match (true) {
184
            $hasMixed || ($hasInput && $hasOutput) => TypeSpace::Mixed,
9✔
185
            $hasInput                              => TypeSpace::Input,
7✔
186
            default                                => TypeSpace::Output,
9✔
187
        };
188
    }
189

190
    /**
191
     * Map a list of PHP type names onto the Input / Output / Mixed / Empty TypeSpace
192
     * based on whether they overlap with the filter's declared input and output types.
193
     *
194
     * Non-primitive class names in the declared output types are expanded to include
195
     * 'object', so that object-targeted Draft keywords (e.g. minProperties) classify as
196
     * output-space when the filter returns a class instance.
197
     *
198
     * 'int' and 'float' are treated as equivalent for overlap detection (JSON Schema:
199
     * integer is a subtype of number). Number-typed Draft keywords such as 'minimum' and
200
     * 'maximum' register under the 'number' → PHP 'float' type, so they must classify as
201
     * output-space for filters that return 'int', and as input-space for filters that
202
     * accept 'int'.
203
     *
204
     * @param string[] $phpTypeNames
205
     */
206
    private function resolveTypeSpace(array $phpTypeNames): TypeSpace
69✔
207
    {
208
        return $this->classifyAgainstSpaces($phpTypeNames, $this->getEffectiveOutputTypes());
69✔
209
    }
210

211
    /**
212
     * Core classification: map a list of PHP type names onto the Input / Output / Mixed /
213
     * Empty TypeSpace based on whether they overlap with the filter's input types and the
214
     * given output type list.
215
     *
216
     * @param string[] $phpTypeNames
217
     * @param string[] $outputTypes
218
     */
219
    private function classifyAgainstSpaces(array $phpTypeNames, array $outputTypes): TypeSpace
69✔
220
    {
221
        $inInput  = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($this->inputTypes)));
69✔
222
        $inOutput = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($outputTypes)));
69✔
223

224
        return match (true) {
225
            $inInput && $inOutput => TypeSpace::Mixed,
69✔
226
            $inInput              => TypeSpace::Input,
69✔
227
            $inOutput             => TypeSpace::Output,
44✔
228
            default               => TypeSpace::Empty,
69✔
229
        };
230
    }
231

232
    /**
233
     * Expand a set of PHP type names so that 'int' and 'float' are treated as
234
     * interchangeable for overlap detection.
235
     *
236
     * JSON Schema defines integer as a subtype of number. In the Draft type registry
237
     * 'number' maps to PHP 'float', so number-typed keywords (minimum, maximum, …)
238
     * carry 'float' as their type name. A filter that returns 'int' should still make
239
     * those keywords classify as output-space; a filter that accepts 'int' should make
240
     * them classify as input-space.
241
     *
242
     * @param string[] $types
243
     * @return string[]
244
     */
245
    private function expandNumericTypes(array $types): array
69✔
246
    {
247
        $hasInt   = in_array('int', $types, true);
69✔
248
        $hasFloat = in_array('float', $types, true);
69✔
249

250
        if ($hasInt && !$hasFloat) {
69✔
251
            $types[] = 'float';
15✔
252
        } elseif ($hasFloat && !$hasInt) {
69✔
NEW
253
            $types[] = 'int';
×
254
        }
255

256
        return $types;
69✔
257
    }
258

259
    /**
260
     * Expand the declared output types to include 'object' when any output type is a
261
     * non-primitive class name. This allows object-type Draft keywords (e.g. minProperties,
262
     * properties) to be classified as output-targeted when the filter returns a class instance.
263
     *
264
     * The 'type' keyword is deliberately excluded from this expansion: it validates raw JSON
265
     * value structure and must classify against the declared output types only (see
266
     * classifyKeyword). This method is used only for non-'type' structural keywords.
267
     *
268
     * @return string[]
269
     */
270
    private function getEffectiveOutputTypes(): array
69✔
271
    {
272
        return array_values(array_unique(array_merge(
69✔
273
            $this->outputTypes,
69✔
274
            array_map(
69✔
275
                static fn(string $type): string => !TypeCheck::isPrimitive($type) && $type !== 'null'
69✔
276
                    ? 'object'
53✔
277
                    : $type,
69✔
278
                $this->outputTypes,
69✔
279
            ),
69✔
280
        )));
69✔
281
    }
282
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc