From fe7c6bbf63c948b66a0fe90dbcde0361dcda2c7d Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 20:12:15 +0300 Subject: [PATCH 1/9] Generate remaining schema keywords (#157) --- CHANGELOG.md | 3 + README.md | 3 +- README.ru.md | 3 +- llms.txt | 9 ++ .../Compile/CompositionArbitraries.php | 22 ++- src/Internal/Compile/ContainerArbitraries.php | 125 +++++++++++++++++- src/Internal/JsonBodyEncoder.php | 64 ++++++++- src/Internal/SchemaShape.php | 3 +- src/SchemaArbitraryCompiler.php | 112 +++++++++++++++- tests/SchemaArbitraryCompilerTest.php | 51 ++++++- 10 files changed, 382 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1563ffb..1919162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- **Added.** Bounded constructive generation for `prefixItems`/`contains` + (including `minContains`/`maxContains`), `dependentRequired`, + `dependentSchemas`, and `unevaluatedProperties`/`unevaluatedItems` (#157). - **Added.** Conditional schemas (`if`/`then`/`else`) are generated constructively, including boolean and nested branches. Each candidate is checked by `openapi-contract`'s `SchemaCheck`; unreachable branches are diff --git a/README.md b/README.md index b29db99..5215508 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ the same way. | `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed | | `$defs` + local `$ref` (`#/$defs/…`) — the contract's compiled form of a schema that refers to itself, a tree whose `children` are trees | supported; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out (an optional property omitted, an array that may be empty left empty, a branch of a choice skipped); a def whose only way down is a required member that is the def itself has no finite instance and fails closed; a `$ref` to anything but the schema's own `$defs` fails closed | | `if`/`then`/`else` (including boolean schemas and nested conditionals) | supported; values are constructed for both reachable branches and checked with the contract's `SchemaCheck`; an unsatisfiable branch is skipped, and a conditional with no producible branch fails closed | -| `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | +| `prefixItems` with `items`/`minItems`/`maxItems`, `contains` with `minContains`/`maxContains`, `unevaluatedItems` | supported for bounded constructive arrays; `contains` emits matching elements by construction, while `prefixItems` emits each tuple position; combinations with `uniqueItems` or conflicting applicators fail closed | +| `dependentRequired`, `dependentSchemas`, `unevaluatedProperties` | supported for bounded plain objects; presence combinations are built explicitly and closures become the additional-property policy; conditional/union/pattern combinations outside this subset fail closed | Every unsatisfiable combination the compiler can recognise is refused at compile time; what remains probabilistic (a `pattern` that rarely matches its diff --git a/README.ru.md b/README.ru.md index 8a35ba6..2d75bd6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -137,7 +137,8 @@ request-направлению схемы — `readOnly`-свойства ухо | `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed | | `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed | | `if`/`then`/`else` (включая boolean schemas и вложенные conditionals) | поддержано; значения конструктивно строятся для обеих достижимых веток и проверяются собственным `SchemaCheck` контракта; невыполнимая ветка пропускается, а conditional без единой производимой ветки падает fail-closed | -| `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | +| `prefixItems` с `items`/`minItems`/`maxItems`, `contains` с `minContains`/`maxContains`, `unevaluatedItems` | поддержано для ограниченных конструктивных массивов; `contains` строит matching-элементы по построению, `prefixItems` — каждую позицию tuple; сочетания с `uniqueItems` или конфликтующими applicator падают fail-closed | +| `dependentRequired`, `dependentSchemas`, `unevaluatedProperties` | поддержано для ограниченных обычных object; комбинации присутствия строятся явно, closure становится политикой дополнительных свойств; conditional/union/pattern вне этого подмножества падают fail-closed | Каждая невыполнимая комбинация, которую компилятор способен распознать, отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко diff --git a/llms.txt b/llms.txt index efda9f6..6f6b6b2 100644 --- a/llms.txt +++ b/llms.txt @@ -312,6 +312,15 @@ side from the base schema and `else`, each side filtered by the condition the contract's own `SchemaCheck` judges. Boolean subschemas are honoured; a side that admits nothing is dropped, and a conditional where neither side survives fails closed. +Bounded tuple arrays use `prefixItems` position by position and `items` for +the tail. `contains` uses an intersection of `items` and the contains schema +so every emitted element is a witness, honoring `minContains`/`maxContains`. +`unevaluatedItems` closes the tail when no `items` schema is present. +`dependentRequired` and simple `dependentSchemas` enumerate trigger presence +combinations and generate each branch constructively; `unevaluatedProperties` +becomes the policy for undeclared members. `uniqueItems` with these applicators +and complex conditional/union/pattern combinations fail closed as +`UnsupportedGeneration`. A schema that refers to itself arrives from the contract as `$defs` plus a local `{$ref: '#/$defs/'}` per reference back; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index e79c690..a8bbdbd 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -40,6 +40,16 @@ public function combinator(array $schema): ?ArbitraryInterface $schemas = $this->schemaBranches($schema[$keyword], $keyword); if ($keyword === 'allOf') { + foreach ($schemas as $branch) { + if (array_key_exists('unevaluatedProperties', $branch) || array_key_exists('unevaluatedItems', $branch)) { + throw UnsupportedGeneration::forSchema('unevaluated keywords inside allOf branches are outside the constructive subset'); + } + } + foreach (['unevaluatedProperties', 'unevaluatedItems'] as $closure) { + if (array_key_exists($closure, $schema)) { + $schemas[] = [$closure => $schema[$closure]]; + } + } [$schemas, $inlined] = $this->inlineReferences($schemas); return $this->definitions->inlining($inlined, fn(): ArbitraryInterface => $this->compiler->compile($this->mergeAllOf($schemas))); @@ -340,7 +350,7 @@ private function valueType(mixed $value): string /** @param list> $branches * @return array */ - private function mergeAllOf(array $branches): array + public function mergeAllOf(array $branches): array { $merged = []; $required = []; @@ -411,6 +421,13 @@ private function mergeAllOf(array $branches): array $merged['required'] = array_keys($required); } $this->assertAdditionalPropertiesAdmitSiblings($branches, array_keys($properties)); + if (array_key_exists('prefixItems', $merged)) { + foreach ($branches as $branch) { + if (array_key_exists('items', $branch) && ($branch['prefixItems'] ?? []) !== $merged['prefixItems']) { + throw UnsupportedGeneration::forSchema('allOf items cannot be moved across sibling prefixItems'); + } + } + } return $merged; } @@ -525,6 +542,9 @@ private function assertNoCombinatorSiblings(array $schema, string $keyword): voi if ($name === $keyword || isset($annotations[$name])) { continue; } + if ($keyword === 'allOf' && in_array($name, ['unevaluatedProperties', 'unevaluatedItems'], strict: true)) { + continue; + } throw UnsupportedGeneration::forSchema(sprintf('%s with sibling keyword "%s" is outside the supported subset', $keyword, $name)); } diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 9b23ea8..dcf3466 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -28,6 +28,9 @@ public function __construct( /** @param array $schema */ public function array(array $schema): ArbitraryInterface { + if (array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema) || array_key_exists('unevaluatedItems', $schema)) { + return $this->extendedArray($schema); + } $items = $schema['items'] ?? null; if (!is_array($items) || array_is_list($items)) { throw UnsupportedGeneration::forSchema('array items must be a schema object'); @@ -61,6 +64,111 @@ public function array(array $schema): ArbitraryInterface return Gen::uniqueArrayOf($element, $min, $max); } + /** @param array $schema */ + private function extendedArray(array $schema): ArbitraryInterface + { + if (($schema['uniqueItems'] ?? false) === true) { + throw UnsupportedGeneration::forSchema('uniqueItems with prefixItems, contains or unevaluatedItems is outside the constructive subset'); + } + $prefix = $schema['prefixItems'] ?? []; + if (!is_array($prefix) || !array_is_list($prefix)) { + throw UnsupportedGeneration::forSchema('prefixItems must be a list of schemas'); + } + $min = $this->facts->nonNegativeInt($schema, 'minItems', 0); + $max = min($this->facts->nonNegativeInt($schema, 'maxItems', self::MAX_COLLECTION_SIZE), self::MAX_COLLECTION_SIZE); + $tail = $this->subschema($schema['items'] ?? true, 'items'); + $contains = array_key_exists('contains', $schema) ? $this->subschema($schema['contains'], 'contains') : null; + $unevaluated = array_key_exists('unevaluatedItems', $schema) ? $this->subschema($schema['unevaluatedItems'], 'unevaluatedItems') : true; + if ($contains !== null) { + if ($prefix !== []) { + throw UnsupportedGeneration::forSchema('contains combined with prefixItems is outside the constructive subset'); + } + $min = max($min, $this->facts->nonNegativeInt($schema, 'minContains', 1)); + $max = min($max, $this->facts->nonNegativeInt($schema, 'maxContains', self::MAX_COLLECTION_SIZE)); + if ($contains === false || $tail === false) { + $max = 0; + } + // Every emitted element is a witness. This bounded subset avoids + // predicting which filler values fail the membership schema. + $tail = $this->intersection($tail, $contains); + } elseif (!array_key_exists('items', $schema)) { + $tail = $unevaluated; + } + if ($tail === false) { + $max = min($max, count($prefix)); + } + $positions = []; + /** @var mixed $member */ + foreach ($prefix as $index => $member) { + $position = $this->subschema($member, 'prefixItems'); + if ($position === false) { + $max = min($max, $index); + + break; + } + if ($index >= $max) { + break; + } + $positions[] = $this->compileSubschema($position); + } + if ($min > $max) { + throw UnsupportedGeneration::forSchema('array bounds cannot be met by the constructive prefixItems/contains subset'); + } + $tailArbitrary = $tail === false ? null : $this->compileSubschema($tail); + $pairs = []; + for ($length = $min; $length <= $max; ++$length) { + $elements = array_slice($positions, 0, $length); + while (count($elements) < $length) { + if ($tailArbitrary === null) { + throw new \LogicException('Closed tuple exceeds its prefix'); + } + $elements[] = $tailArbitrary; + } + $pairs[] = [1, Gen::tuple(...$elements)]; + } + + return Gen::frequency($pairs); + } + + /** @return bool|array */ + private function subschema(mixed $value, string $keyword): bool|array + { + return is_bool($value) ? $value : $this->facts->schemaObject($value, sprintf('%s must be a boolean or schema object', $keyword)); + } + + /** @param bool|array $schema */ + private function compileSubschema(bool|array $schema): ArbitraryInterface + { + if ($schema === false) { + /** @var ArbitraryInterface $impossible */ + $impossible = Gen::filter(Gen::constant(value: null), static fn(): bool => false); + + return $impossible; + } + + return $schema === true ? $this->additionalValue() : $this->compiler->compile($schema); + } + + /** + * @param bool|array $left + * @param bool|array $right + * @return bool|array + */ + private function intersection(bool|array $left, bool|array $right): bool|array + { + if ($left === false || $right === false) { + return false; + } + if ($left === true || $left === []) { + return $right; + } + if ($right === true || $right === []) { + return $left; + } + + return ['allOf' => [$left, $right]]; + } + /** * The number of distinct values an item schema can take when that number * is knowable from its shape alone. @@ -93,8 +201,11 @@ private function finiteDomain(array $items): ?int }; } - /** @param array $schema */ - public function object(array $schema): ArbitraryInterface + /** + * @param array $schema + * @param list $excluded + */ + public function object(array $schema, array $excluded = []): ArbitraryInterface { $properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object'); $patterns = $this->facts->schemaObject($schema['patternProperties'] ?? [], 'patternProperties must be an object of schemas'); @@ -147,6 +258,13 @@ public function object(array $schema): ArbitraryInterface $omitted = $this->direction?->foreignFlag(); /** @var array $reserved */ $reserved = []; + foreach ($excluded as $name) { + $reserved[$name] = true; + if (isset($requiredNames[$name])) { + throw UnsupportedGeneration::forSchema('dependent schema requires an inactive trigger'); + } + unset($properties[$name]); + } /** @var array $requiredNames */ foreach ($properties as $name => $property) { if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) { @@ -195,6 +313,9 @@ public function object(array $schema): ArbitraryInterface } $additional = $this->facts->additionalPropertiesSchema($schema); + if (!array_key_exists('additionalProperties', $schema) && array_key_exists('unevaluatedProperties', $schema)) { + $additional = $this->subschema($schema['unevaluatedProperties'], 'unevaluatedProperties'); + } if ($additional === false && $patternArbitraries === [] && $minProperties > count($shape)) { throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false'); } diff --git a/src/Internal/JsonBodyEncoder.php b/src/Internal/JsonBodyEncoder.php index 8d43742..b251145 100644 --- a/src/Internal/JsonBodyEncoder.php +++ b/src/Internal/JsonBodyEncoder.php @@ -49,10 +49,25 @@ private function jsonValueIn(mixed $value, array $schema, array $defs): mixed if (isset($schema['$ref'])) { $schema = $this->dereference($schema['$ref'], $defs); } + $schema = $this->encodingSchema($schema, $value); if (SchemaShape::isArray($schema) && is_array($value) && array_is_list($value)) { - $items = $this->schemaObject($schema['items'] ?? null, 'Array items must be a schema object'); + $result = []; + /** @var mixed $item */ + foreach ($value as $index => $item) { + /** @var mixed $prefix */ + $prefix = $schema['prefixItems'] ?? []; + /** @var mixed $member */ + $member = is_array($prefix) && array_key_exists($index, $prefix) + ? $prefix[$index] + : ($schema['items'] ?? $schema['contains'] ?? $schema['unevaluatedItems'] ?? []); + $items = is_bool($member) ? [] : $this->schemaObject($member, 'Array items must be a schema object'); + if (is_array($schema['contains'] ?? null)) { + $items = $this->encodingSchema(['allOf' => [$items, $schema['contains']]], $item); + } + $result = [...$result, $this->jsonValueIn($item, $items, $defs)]; + } - return array_map(fn(mixed $item): mixed => $this->jsonValueIn($item, $items, $defs), $value); + return $result; } // `array_is_list()` is not a guess here: a negative case deliberately // sends a list where an object is declared, and the wire has to carry @@ -108,7 +123,50 @@ private function dereference(mixed $reference, array $defs): array private function additionalSchema(array $schema): mixed { // `true` and `false` carry no shape of their own. - return is_array($schema['additionalProperties'] ?? null) ? $schema['additionalProperties'] : []; + /** @var mixed $member */ + $member = $schema['additionalProperties'] ?? $schema['unevaluatedProperties'] ?? []; + + return is_array($member) ? $member : []; + } + + /** + * @param array $schema + * @return array + */ + private function encodingSchema(array $schema, mixed $value): array + { + /** @var mixed $branches */ + $branches = $schema['allOf'] ?? []; + if (!is_array($branches)) { + $branches = []; + } + if (is_array($value) && is_array($schema['dependentSchemas'] ?? null)) { + /** @var array $dependentSchemas */ + $dependentSchemas = $schema['dependentSchemas']; + foreach (array_keys($dependentSchemas) as $trigger) { + if (array_key_exists($trigger, $value)) { + $branches = [...$branches, $dependentSchemas[$trigger]]; + } + } + } + foreach ($branches as $branch) { + if (!is_array($branch)) { + continue; + } + $branch = $this->encodingSchema($this->schemaObject($branch, 'Applicator branch must be a schema object'), $value); + foreach (['type', 'items', 'prefixItems'] as $keyword) { + if (!array_key_exists($keyword, $schema) && array_key_exists($keyword, $branch)) { + $schema = [...$schema, $keyword => $branch[$keyword]]; + } + } + if (is_array($branch['properties'] ?? null)) { + $properties = $this->memberMap($schema['properties'] ?? [], 'Object properties must be an object'); + $members = $this->memberMap($branch['properties'], 'Object properties must be an object'); + $schema['properties'] = array_replace($properties, $members); + } + } + + return $schema; } /** diff --git a/src/Internal/SchemaShape.php b/src/Internal/SchemaShape.php index 5cb85bc..1c75c22 100644 --- a/src/Internal/SchemaShape.php +++ b/src/Internal/SchemaShape.php @@ -16,7 +16,8 @@ /** @param array $schema */ public static function isArray(array $schema): bool { - return self::declares($schema, 'array') || array_key_exists('items', $schema); + return self::declares($schema, 'array') || array_key_exists('items', $schema) + || array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema); } /** @param array $schema */ diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index 7a31ce7..ad0a046 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -91,6 +91,13 @@ public function compile(array $schema): ArbitraryInterface if (array_key_exists('$ref', $schema)) { return $this->reference($schema['$ref']); } + if (array_key_exists('dependentRequired', $schema) || array_key_exists('dependentSchemas', $schema)) { + return $this->dependencies($schema); + } + if ((array_key_exists('unevaluatedProperties', $schema) || array_key_exists('unevaluatedItems', $schema)) + && (array_key_exists('if', $schema) || array_key_exists('anyOf', $schema) || array_key_exists('oneOf', $schema))) { + throw UnsupportedGeneration::forSchema('unevaluated keywords with conditional or union applicators are outside the constructive subset'); + } if (array_key_exists('if', $schema) || array_key_exists('then', $schema) || array_key_exists('else', $schema)) { return $this->conditional($schema); } @@ -218,6 +225,104 @@ private function conditional(array $schema): ArbitraryInterface return Gen::frequency($pairs); } + /** @param array $schema */ + private function dependencies(array $schema): ArbitraryInterface + { + if (($schema['type'] ?? 'object') !== 'object' || array_key_exists('patternProperties', $schema) + || array_key_exists('if', $schema) || array_key_exists('allOf', $schema) + || array_key_exists('anyOf', $schema) || array_key_exists('oneOf', $schema)) { + throw UnsupportedGeneration::forSchema('dependent keywords require a plain object schema'); + } + $requiredMap = $this->facts->schemaObject($schema['dependentRequired'] ?? [], 'dependentRequired must be an object of lists'); + $schemaMap = $this->facts->schemaObject($schema['dependentSchemas'] ?? [], 'dependentSchemas must be an object of schemas'); + /** @var array $requiredMap */ + /** @var array $schemaMap */ + $properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object'); + /** @var list $triggers */ + $triggers = array_values(array_unique([...array_keys($requiredMap), ...array_keys($schemaMap)])); + if (count($triggers) > 8) { + throw UnsupportedGeneration::forSchema('dependent keywords exceed the eight-trigger generation budget'); + } + foreach ($triggers as $trigger) { + if (!array_key_exists($trigger, $properties)) { + throw UnsupportedGeneration::forSchema('dependent trigger must have a declared property schema'); + } + } + foreach ($requiredMap as $members) { + if (!is_array($members) || !array_is_list($members)) { + throw UnsupportedGeneration::forSchema('dependentRequired values must be lists of names'); + } + /** @var mixed $member */ + foreach ($members as $member) { + if (!is_string($member)) { + throw UnsupportedGeneration::forSchema('dependentRequired values must contain names'); + } + } + } + /** @var mixed $member */ + foreach ($schemaMap as $member) { + if (!is_array($member) || ($member !== [] && array_is_list($member))) { + throw UnsupportedGeneration::forSchema('dependentSchemas values must be schema objects'); + } + /** @var array $member */ + foreach (array_keys($member) as $keyword) { + if (!in_array($keyword, ['type', 'required', 'properties', 'minProperties', 'maxProperties', 'additionalProperties', 'description', 'title'], strict: true)) { + throw UnsupportedGeneration::forSchema(sprintf('dependentSchemas keyword "%s" is outside the constructive subset', $keyword)); + } + } + } + $foreignFlag = $this->direction?->foreignFlag(); + if ($foreignFlag !== null) { + /** @var mixed $property */ + foreach ($properties as $property) { + if (is_array($property) && ($property[$foreignFlag] ?? false) === true) { + throw UnsupportedGeneration::forSchema('dependent keywords with foreign-direction properties are outside the constructive subset'); + } + } + } + unset($schema['dependentRequired'], $schema['dependentSchemas']); + $pairs = []; + for ($mask = 0; $mask < (1 << count($triggers)); ++$mask) { + $present = []; + $absent = []; + $branches = [$schema]; + foreach ($triggers as $index => $trigger) { + $triggerName = (string) $trigger; + if (($mask & (1 << $index)) === 0) { + $absent[] = $triggerName; + + continue; + } + $present[] = $triggerName; + if (isset($requiredMap[$triggerName])) { + /** @var list $members */ + $members = $requiredMap[$triggerName]; + $branches[] = ['required' => $members]; + } + if (isset($schemaMap[$triggerName])) { + /** @var array $dependent */ + $dependent = $schemaMap[$triggerName]; + $branches[] = $dependent; + } + } + $branches[] = ['required' => $present]; + + try { + $merged = $this->composition->mergeAllOf($branches); + $this->assertSupported($merged); + $pairs[] = [1, $this->containers->object($merged, $absent)]; + } catch (UnsupportedGeneration|Unproducible) { + // A presence combination can be impossible: a required + // trigger is absent, or its dependency exceeds cardinality. + } + } + if ($pairs === []) { + throw UnsupportedGeneration::forSchema('dependent keywords admit no constructible presence combination'); + } + + return Gen::frequency($pairs); + } + /** @return bool|array */ private function conditionSchema(mixed $value, string $keyword): bool|array { @@ -333,7 +438,10 @@ private function type(array $schema): string if (array_key_exists('properties', $schema)) { return 'object'; } - if (array_key_exists('items', $schema)) { + if (array_key_exists('dependentRequired', $schema) || array_key_exists('dependentSchemas', $schema) || array_key_exists('unevaluatedProperties', $schema)) { + return 'object'; + } + if (array_key_exists('items', $schema) || array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema)) { return 'array'; } @@ -345,8 +453,6 @@ private function assertSupported(array $schema): void { foreach ([ 'allOf', 'anyOf', 'oneOf', - 'contains', 'prefixItems', 'unevaluatedProperties', - 'dependentRequired', 'dependentSchemas', 'unevaluatedItems', ] as $keyword) { if (array_key_exists($keyword, $schema)) { throw UnsupportedGeneration::forSchema(sprintf('keyword "%s" is outside the initial support matrix', $keyword)); diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 2825f6f..64e15d8 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -604,11 +604,60 @@ public function rejectsUnsupportedAssertionKeywords(string $keyword): void /** @return iterable */ public static function unsupportedKeywordCases(): iterable { - foreach (['allOf', 'anyOf', 'oneOf', 'contains', 'prefixItems', 'unevaluatedProperties', 'dependentRequired', 'dependentSchemas', 'unevaluatedItems'] as $keyword) { + foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) { yield $keyword => [$keyword]; } } + public function generatesTupleAndContainsArrays(): void + { + $compiler = new SchemaArbitraryCompiler(); + $tuple = Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'v1'], ['type' => 'integer', 'minimum' => 1, 'maximum' => 1]], + 'items' => false, + 'minItems' => 2, + 'maxItems' => 2, + ]), count: 20, seed: 19); + foreach ($tuple as $value) { + Assert::same($value, ['v1', 1]); + } + $contains = $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 2, + 'maxContains' => 2, + 'minItems' => 2, + 'maxItems' => 2, + ]); + foreach (Gen::sample($contains, count: 20, seed: 23) as $value) { + Assert::true(is_array($value) && count($value) === 2); + Assert::true($value[0] >= 5 && $value[1] >= 5); + } + } + + public function generatesDependentAndUnevaluatedObjects(): void + { + $compiler = new SchemaArbitraryCompiler(); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'card' => ['const' => 'x'], + 'address' => ['type' => 'string'], + ], + 'dependentRequired' => ['card' => ['address']], + 'unevaluatedProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 29) as $value) { + Assert::true(is_array($value)); + if (array_key_exists('card', $value)) { + Assert::true(array_key_exists('address', $value)); + } + Assert::true(array_diff(array_keys($value), ['card', 'address']) === []); + } + } + public function rejectsMalformedSchemaCombinatorsAndObjects(): void { $compiler = new SchemaArbitraryCompiler(); From 0d979abd6a0574e9e39e91ad4bab39d926a9ac43 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 20:23:33 +0300 Subject: [PATCH 2/9] Fix Rector and Psalm findings --- src/Internal/Compile/ContainerArbitraries.php | 2 +- src/Internal/JsonBodyEncoder.php | 40 ++++++++++++------- src/SchemaArbitraryCompiler.php | 8 ++-- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index dcf3466..304d8d6 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -119,7 +119,7 @@ private function extendedArray(array $schema): ArbitraryInterface for ($length = $min; $length <= $max; ++$length) { $elements = array_slice($positions, 0, $length); while (count($elements) < $length) { - if ($tailArbitrary === null) { + if (!$tailArbitrary instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) { throw new \LogicException('Closed tuple exceeds its prefix'); } $elements[] = $tailArbitrary; diff --git a/src/Internal/JsonBodyEncoder.php b/src/Internal/JsonBodyEncoder.php index b251145..b62c75b 100644 --- a/src/Internal/JsonBodyEncoder.php +++ b/src/Internal/JsonBodyEncoder.php @@ -54,12 +54,11 @@ private function jsonValueIn(mixed $value, array $schema, array $defs): mixed $result = []; /** @var mixed $item */ foreach ($value as $index => $item) { - /** @var mixed $prefix */ - $prefix = $schema['prefixItems'] ?? []; - /** @var mixed $member */ - $member = is_array($prefix) && array_key_exists($index, $prefix) + /** @var array $prefix */ + $prefix = is_array($schema['prefixItems'] ?? null) ? $schema['prefixItems'] : []; + $member = $this->memberSchema(array_key_exists($index, $prefix) ? $prefix[$index] - : ($schema['items'] ?? $schema['contains'] ?? $schema['unevaluatedItems'] ?? []); + : ($schema['items'] ?? $schema['contains'] ?? $schema['unevaluatedItems'] ?? [])); $items = is_bool($member) ? [] : $this->schemaObject($member, 'Array items must be a schema object'); if (is_array($schema['contains'] ?? null)) { $items = $this->encodingSchema(['allOf' => [$items, $schema['contains']]], $item); @@ -123,10 +122,14 @@ private function dereference(mixed $reference, array $defs): array private function additionalSchema(array $schema): mixed { // `true` and `false` carry no shape of their own. - /** @var mixed $member */ - $member = $schema['additionalProperties'] ?? $schema['unevaluatedProperties'] ?? []; + if (is_array($schema['additionalProperties'] ?? null)) { + return $schema['additionalProperties']; + } + if (is_array($schema['unevaluatedProperties'] ?? null)) { + return $schema['unevaluatedProperties']; + } - return is_array($member) ? $member : []; + return []; } /** @@ -135,17 +138,14 @@ private function additionalSchema(array $schema): mixed */ private function encodingSchema(array $schema, mixed $value): array { - /** @var mixed $branches */ - $branches = $schema['allOf'] ?? []; - if (!is_array($branches)) { - $branches = []; - } + /** @var list $branches */ + $branches = is_array($schema['allOf'] ?? null) ? $schema['allOf'] : []; if (is_array($value) && is_array($schema['dependentSchemas'] ?? null)) { /** @var array $dependentSchemas */ $dependentSchemas = $schema['dependentSchemas']; foreach (array_keys($dependentSchemas) as $trigger) { - if (array_key_exists($trigger, $value)) { - $branches = [...$branches, $dependentSchemas[$trigger]]; + if (array_key_exists($trigger, $value) && is_array($dependentSchemas[$trigger] ?? null)) { + $branches[] = $this->schemaObject($dependentSchemas[$trigger], 'Dependent schema must be an object'); } } } @@ -169,6 +169,16 @@ private function encodingSchema(array $schema, mixed $value): array return $schema; } + /** @return bool|array */ + private function memberSchema(mixed $value): bool|array + { + if (is_bool($value)) { + return $value; + } + + return $this->schemaObject($value, 'Array items must be a schema object'); + } + /** * A Schema Object: a map keyed by JSON Schema keywords, which are never * numeric, so `array` is a promise PHP can keep here. diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index ad0a046..c7812fd 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -235,10 +235,10 @@ private function dependencies(array $schema): ArbitraryInterface } $requiredMap = $this->facts->schemaObject($schema['dependentRequired'] ?? [], 'dependentRequired must be an object of lists'); $schemaMap = $this->facts->schemaObject($schema['dependentSchemas'] ?? [], 'dependentSchemas must be an object of schemas'); - /** @var array $requiredMap */ - /** @var array $schemaMap */ + /** @var array $requiredMap */ + /** @var array $schemaMap */ $properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object'); - /** @var list $triggers */ + /** @var list $triggers */ $triggers = array_values(array_unique([...array_keys($requiredMap), ...array_keys($schemaMap)])); if (count($triggers) > 8) { throw UnsupportedGeneration::forSchema('dependent keywords exceed the eight-trigger generation budget'); @@ -287,7 +287,7 @@ private function dependencies(array $schema): ArbitraryInterface $absent = []; $branches = [$schema]; foreach ($triggers as $index => $trigger) { - $triggerName = (string) $trigger; + $triggerName = $trigger; if (($mask & (1 << $index)) === 0) { $absent[] = $triggerName; From 2a2912661878e08fc97a330a96becfb9596afc9b Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:20:00 +0300 Subject: [PATCH 3/9] Generate a member against the schema of its own name (#157) `patternProperties` says that the value under a name answers the schema of the patterns that name matches. The compiler built the value once for the whole map and paired it with keys drawn independently, so every name received the schema compiled for whichever name came first: an object whose `^a` member carried a boolean and whose `z$` member carried a string. Two of every three generated objects were rejected by the contract's own validator. The name and the value it declares are now drawn together, through `Gen::uniqueArrayOf` keyed by name. `Gen::dictOf()` cannot express this: it draws keys and values from separate arbitraries. Also raises the mutation score over the #157 keywords - the extended array bounds, the dependent-trigger masks, the direction-owned properties, the refusal messages, and the type a structural keyword implies - each pinned by the answer it produces rather than by the class of exception it throws. --- CHANGELOG.md | 5 + src/Internal/Compile/ContainerArbitraries.php | 100 ++- tests/ResponseMaterializerTest.php | 82 ++ tests/SchemaArbitraryCompilerTest.php | 761 ++++++++++++++++++ 4 files changed, 911 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1919162..ed6a195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 constructively, including boolean and nested branches. Each candidate is checked by `openapi-contract`'s `SchemaCheck`; unreachable branches are omitted and a conditional with no finite value fails closed (#157). +- **Fixed.** A member under `patternProperties` is now generated against the + schema of the name it actually lands under. The value was compiled once for + the whole map, so every name received the schema of whichever name was drawn + first — an object whose `^a` member carried a boolean and whose `z$` member + carried a string, which the contract rejects (#157). ## 0.18.0 — 2026-09-29 diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 304d8d6..484b516 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -124,7 +124,7 @@ private function extendedArray(array $schema): ArbitraryInterface } $elements[] = $tailArbitrary; } - $pairs[] = [1, Gen::tuple(...$elements)]; + $pairs[] = [1, $elements === [] ? Gen::constant(value: []) : Gen::tuple(...$elements)]; } return Gen::frequency($pairs); @@ -347,9 +347,9 @@ public function object(array $schema, array $excluded = []): ArbitraryInterface } $keyAlphabet = 'abcdefghijklmnopqrstuvwxyz'; - /** @var ArbitraryInterface $key */ + /** @var ArbitraryInterface $name */ if ($patternArbitraries !== []) { - $key = Gen::map(Gen::frequency(array_map( + $name = Gen::map(Gen::frequency(array_map( function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): array { /** @var string $pattern */ [$pattern, $source] = $entry; @@ -375,7 +375,7 @@ function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): arra $patternArbitraries, )), static fn(string $name): string => $name); } else { - $key = Gen::map( + $name = Gen::map( Gen::filter( Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8), fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]) @@ -384,39 +384,43 @@ function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): arra static fn(string $name): string => $name, ); } - /** @var ArbitraryInterface $value */ - if ($patternArbitraries !== []) { - $value = Gen::flatMap($key, function (mixed $name) use ($patterns, $additional): ArbitraryInterface { - if (is_string($name)) { - $matching = []; - /** @var list> $matching */ - foreach ($patterns as $pattern => $patternSchema) { - if (preg_match('~' . $pattern . '~', $name) === 1) { - /** @var array $patternSchema */ - $matching[] = $patternSchema; - } - } - if ($matching !== []) { - return $this->compiler->compile(['allOf' => $matching]); + $value = is_array($additional) && $additional !== [] + ? $this->compiler->compile($additional) + : $this->additionalValue(); + // The schema a member answers can depend on the name it lands under — + // that is what `patternProperties` means — so the name and its value + // are drawn together. A value arbitrary picked once for the whole map + // hands every name the value compiled for whichever name came first, + // and an object where `a` carries the boolean and `z` the string is + // exactly what then reaches the contract and fails validation. + /** @var ArbitraryInterface $entry */ + $entry = Gen::flatMap($name, function (mixed $drawn) use ($patterns, $value): ArbitraryInterface { + $matching = []; + if (is_string($drawn)) { + /** @var list> $matching */ + foreach ($patterns as $pattern => $patternSchema) { + if (preg_match('~' . $pattern . '~', $drawn) === 1) { + /** @var array $patternSchema */ + $matching[] = $patternSchema; } } - - return is_array($additional) && $additional !== [] - ? $this->compiler->compile($additional) - : $this->additionalValue(); - }); - } else { - $value = is_array($additional) && $additional !== [] - ? $this->compiler->compile($additional) - : $this->additionalValue(); - } + } + $member = $matching === [] + ? $value + : $this->compiler->compile(['allOf' => $matching]); + + return Gen::map( + Gen::tuple(Gen::constant(value: $drawn), $member), + /** @param list $pair @return array{0: string, 1: mixed} */ + static fn(array $pair): array => [$pair[0], $pair[1]], + ); + }); return Gen::flatMap($base, fn(array $values): ArbitraryInterface => $this->additionalProperties( $values, $minProperties, $maxProperties, - $key, - $value, + $entry, )); } @@ -462,16 +466,15 @@ public function additionalValue(): ArbitraryInterface /** * @param array $values - * @param ArbitraryInterface $key - * @param ArbitraryInterface $value + * @param ArbitraryInterface $entry a member + * name drawn together with the value that name declares * @return ArbitraryInterface> */ private function additionalProperties( array $values, int $minProperties, int $maxProperties, - ArbitraryInterface $key, - ArbitraryInterface $value, + ArbitraryInterface $entry, ): ArbitraryInterface { $missing = max(0, $minProperties - count($values)); $room = max(0, $maxProperties - count($values)); @@ -482,10 +485,33 @@ private function additionalProperties( return $result; } - $extras = Gen::dictOf($key, $value, minSize: $missing, maxSize: $room); + $extras = Gen::uniqueArrayOf( + $entry, + minSize: $missing, + maxSize: $room, + /** @param mixed $member */ + by: static function (mixed $member): string { + if (!is_array($member)) { + throw new \LogicException('Generated additional property has an invalid shape'); + } + $name = $member[0] ?? null; + if (!is_string($name)) { + throw new \LogicException('Generated additional property has an invalid name'); + } + + return $name; + }, + ); + + $result = Gen::map($extras, static function (array $extra) use ($values): array { + $typed = []; + /** @var array{0: string, 1: mixed} $member */ + foreach ($extra as $member) { + $typed = array_replace($typed, [$member[0] => $member[1]]); + } - /** @var ArbitraryInterface> $result */ - $result = Gen::map($extras, static fn(array $extra): array => array_replace($values, $extra)); + return array_replace($values, $typed); + }); return $result; } diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index 16182b5..5102074 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -184,6 +184,14 @@ public function jsonEncoderKeepsEmptyObjectsInsideLists(): void Assert::same((new JsonBodyEncoder())->encode([[]], ['items' => ['type' => 'object']]), '[{}]'); Assert::same((new JsonBodyEncoder())->encode(['a', 'b'], ['type' => 'object']), '["a","b"]'); Assert::same((new JsonBodyEncoder())->encode(['a' => [], 'b' => []], ['type' => 'object', 'properties' => ['a' => ['type' => 'object'], 'b' => ['type' => 'object']]]), '{"a":{},"b":{}}'); + Assert::same( + (new JsonBodyEncoder())->encode(['v1', 2], ['type' => 'array', 'prefixItems' => [['type' => 'string'], ['type' => 'integer']]]), + '["v1",2]', + ); + Assert::same( + (new JsonBodyEncoder())->encode([5], ['type' => 'array', 'contains' => ['type' => 'integer']]), + '[5]', + ); } /** @@ -205,6 +213,80 @@ public function jsonEncoderReadsTheAdditionalPropertiesSchema(): void $encoder->encode(['k' => []], ['type' => 'object', 'properties' => ['k' => []], 'additionalProperties' => ['type' => 'object']]), '{"k":[]}', ); + Assert::same( + $encoder->encode(['k' => []], ['type' => 'object', 'unevaluatedProperties' => ['type' => 'object']]), + '{"k":{}}', + ); + Assert::same( + $encoder->encode(['kind' => 'x', 'detail' => []], [ + 'type' => 'object', + 'dependentSchemas' => ['kind' => ['properties' => ['detail' => ['type' => 'object']]]], + ]), + '{"kind":"x","detail":{}}', + ); + } + + public function encodesTheTailOfAPrefixTupleAndNestedApplicators(): void + { + $encoder = new JsonBodyEncoder(); + + // An element past `prefixItems` is declared by `contains` when there + // is no `items`, and by `unevaluatedItems` when there is neither. + Assert::same( + $encoder->encode(['a', 2, 3], [ + 'type' => 'array', + 'prefixItems' => [['type' => 'string']], + 'contains' => ['type' => 'integer'], + ]), + '["a",2,3]', + ); + Assert::same( + $encoder->encode([[], []], [ + 'type' => 'array', + 'unevaluatedItems' => ['type' => 'object'], + ]), + '[{},{}]', + ); + + // An `allOf` branch that declares the array shape the root leaves out. + Assert::same( + $encoder->encode(['k' => []], [ + 'allOf' => [['properties' => ['k' => ['type' => 'object']]]], + ]), + '{"k":{}}', + ); + + // A dependent branch contributes its members only when the trigger is + // there; an absent trigger leaves the member undeclared, and an + // undeclared empty member keeps the array encoding it arrived with. + Assert::same( + $encoder->encode(['other' => []], [ + 'type' => 'object', + 'dependentSchemas' => ['kind' => ['properties' => ['other' => ['type' => 'object']]]], + ]), + '{"other":[]}', + ); + + // `items` wins over `contains`: the first element reads as a string, + // which only encodes as one if `items` was consulted. + Assert::same( + $encoder->encode(['x'], ['type' => 'array', 'items' => ['type' => 'string'], 'contains' => ['type' => 'integer']]), + '["x"]', + ); + + // The root's `$defs` is what a local `$ref` below it resolves against, + // and the referenced shape decides the encoding. + Assert::same( + $encoder->encode( + ['k' => []], + [ + 'type' => 'object', + '$defs' => ['node' => ['type' => 'object', 'properties' => ['k' => ['type' => 'object']]]], + 'additionalProperties' => ['$ref' => '#/$defs/node'], + ], + ), + '{"k":{}}', + ); } /** diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 64e15d8..a1547e3 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -8,6 +8,7 @@ use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\SchemaCheck; use Rasuvaeff\OpenApiContract\SchemaDialect; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; @@ -635,6 +636,43 @@ public function generatesTupleAndContainsArrays(): void Assert::true(is_array($value) && count($value) === 2); Assert::true($value[0] >= 5 && $value[1] >= 5); } + + $openTuple = Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'head']], + 'items' => false, + 'minItems' => 0, + 'maxItems' => 1, + ]), count: 20, seed: 31); + Assert::true(in_array([], $openTuple, strict: true)); + Assert::true(in_array(['head'], $openTuple, strict: true)); + + Expect::exception(UnsupportedGeneration::class); + $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['type' => 'string']], + 'contains' => ['type' => 'string'], + ]); + } + + public function rejectsImpossibleExtendedArraysAndInvalidShapes(): void + { + $compiler = new SchemaArbitraryCompiler(); + foreach ([ + ['type' => 'array', 'prefixItems' => 'invalid'], + ['type' => 'array', 'prefixItems' => [['type' => 'string']], 'items' => false, 'minItems' => 2, 'maxItems' => 2], + ['type' => 'array', 'contains' => false, 'minContains' => 1], + ['type' => 'array', 'contains' => ['type' => 'integer'], 'items' => false, 'minItems' => 1], + ['type' => 'array', 'unevaluatedItems' => false, 'minItems' => 1], + ['type' => 'array', 'prefixItems' => [['type' => 'string']], 'uniqueItems' => true], + ] as $schema) { + try { + $compiler->compile($schema); + Assert::true(actual: false); + } catch (UnsupportedGeneration) { + Assert::true(actual: true); + } + } } public function generatesDependentAndUnevaluatedObjects(): void @@ -656,6 +694,65 @@ public function generatesDependentAndUnevaluatedObjects(): void } Assert::true(array_diff(array_keys($value), ['card', 'address']) === []); } + + // An object-typed `unevaluatedProperties` is what an undeclared member + // is declared by, exactly as `additionalProperties` would be — and it + // is the `additionalProperties` key that wins when both are present. + $unevaluated = [ + 'type' => 'object', + 'properties' => ['card' => ['const' => 'x']], + 'unevaluatedProperties' => ['type' => 'integer'], + ]; + foreach (Gen::sample($compiler->compile($unevaluated), count: 40, seed: 31) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'card') { + Assert::true(is_int($member)); + } + } + } + $declared = $unevaluated; + $declared['additionalProperties'] = ['type' => 'boolean']; + foreach (Gen::sample($compiler->compile($declared), count: 40, seed: 37) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'card') { + Assert::true(is_bool($member)); + } + } + } + + $dependent = [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['const' => 'required'], + ], + 'dependentSchemas' => [ + 'kind' => ['required' => ['detail']], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($dependent), count: 100, seed: 41) as $value) { + Assert::true(is_array($value)); + if (array_key_exists('kind', $value)) { + Assert::same($value['detail'], 'required'); + } + } + + foreach ([ + ['type' => 'object', 'dependentRequired' => ['missing' => []]], + ['type' => 'object', 'dependentRequired' => ['x' => 'invalid']], + ['type' => 'object', 'dependentSchemas' => ['x' => ['items' => []]]], + ['type' => 'object', 'dependentRequired' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], [])], + ] as $invalid) { + try { + $compiler->compile($invalid); + Assert::true(actual: false); + } catch (UnsupportedGeneration) { + Assert::true(actual: true); + } + } } public function rejectsMalformedSchemaCombinatorsAndObjects(): void @@ -2120,4 +2217,668 @@ public function nullableWithAnEnumYieldsNullOnlyWhenTheEnumListsIt(): void Assert::true($sawNull, 'an enum listing null never produced it'); } + + public function extendedArraysHonourContainsCardinalityAndClosedTails(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `minContains` above `minItems`, and a tail that must satisfy both + // `items` and `contains`. + $witnesses = $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 3, + 'maxItems' => 4, + ]); + foreach (Gen::sample($witnesses, count: 20, seed: 53) as $value) { + Assert::true(is_array($value) && count($value) >= 3); + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 5); + } + } + + // `maxContains` below `maxItems` bounds the length from above. + $bounded = $compiler->compile([ + 'type' => 'array', + 'contains' => ['type' => 'boolean'], + 'minContains' => 0, + 'maxContains' => 1, + 'maxItems' => 5, + ]); + foreach (Gen::sample($bounded, count: 20, seed: 59) as $value) { + Assert::true(is_array($value) && count($value) <= 1); + } + + // `contains: false` with no `items` admits nothing but the empty array. + $refused = $compiler->compile([ + 'type' => 'array', + 'contains' => false, + 'minContains' => 0, + ]); + Assert::same(Gen::sample($refused, count: 5, seed: 61), [[], [], [], [], []]); + + // `unevaluatedItems` is the tail of an array that declares no `items`. + $unevaluated = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'head']], + 'unevaluatedItems' => ['type' => 'string', 'maxLength' => 3], + 'minItems' => 1, + 'maxItems' => 3, + ]); + foreach (Gen::sample($unevaluated, count: 20, seed: 67) as $value) { + Assert::true(is_array($value) && $value !== [] && $value[0] === 'head'); + foreach (array_slice($value, 1) as $item) { + Assert::true(is_string($item) && mb_strlen($item) <= 3); + } + } + + // A prefix that closes the tuple: `items: false` caps the length at + // the prefix, and a `false` member closes it earlier still. + $closed = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], ['const' => 'b'], ['const' => 'c']], + 'items' => false, + ]); + $lengths = []; + foreach (Gen::sample($closed, count: 30, seed: 71) as $value) { + Assert::true(is_array($value)); + $lengths[count($value)] = true; + } + Assert::true(count($lengths) === 4, 'a closed prefix did not offer every length'); + + // A `false` prefix member truncates the tuple at that index. + $truncated = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], false, ['const' => 'c']], + ]); + foreach (Gen::sample($truncated, count: 20, seed: 73) as $value) { + Assert::true(is_array($value) && count($value) <= 1); + } + + // A prefix longer than `maxItems` contributes only what fits. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], ['const' => 'b'], ['const' => 'c']], + 'items' => false, + 'maxItems' => 2, + ]), count: 10, seed: 79) as $value) { + Assert::true(is_array($value) && count($value) <= 2); + } + } + + public function everyDependentTriggerCombinationIsReachable(): void + { + $compiler = new SchemaArbitraryCompiler(); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'boolean'], + 'b' => ['type' => 'boolean'], + 'x' => ['type' => 'boolean'], + 'y' => ['type' => 'boolean'], + ], + 'dependentRequired' => ['a' => ['x'], 'b' => ['y']], + 'additionalProperties' => false, + ]; + $check = new SchemaCheck(); + $seen = []; + foreach (Gen::sample($compiler->compile($schema), count: 200, seed: 83) as $value) { + Assert::true(is_array($value)); + Assert::true($check->accepts((object) $value, $schema, SchemaDialect::OpenApi31)); + // Only the two triggers are enumerated; `x`/`y` follow from them. + $seen[implode(',', array_intersect(['a', 'b'], array_keys($value)))] = true; + } + + // Every one of the eight presence masks must be constructible: the + // enumeration that builds them is the code under test. + Assert::same(count($seen), 4, 'not every dependent trigger mask was generated'); + + // A trigger that arrives through `dependentSchemas` rather than + // `dependentRequired`: the two maps are merged into one trigger list. + $pair = [ + 'type' => 'object', + 'properties' => ['x' => ['type' => 'string'], 'y' => ['type' => 'string'], 'z' => ['type' => 'string']], + 'dependentRequired' => ['x' => ['y']], + 'dependentSchemas' => ['y' => ['required' => ['z']]], + ]; + $masks = []; + foreach (Gen::sample($compiler->compile($pair), count: 200, seed: 89) as $value) { + Assert::true(is_array($value)); + Assert::true($check->accepts((object) $value, $pair, SchemaDialect::OpenApi31)); + $masks[implode(',', array_intersect(['x', 'y'], array_keys($value)))] = true; + } + // Three of the four masks: `x` present with `y` absent is the one the + // enumeration has to notice and drop, and dropping it is the code + // under test — a mask that survived would fail the check above. + Assert::same(count($masks), 3); + Assert::false(isset($masks['x'])); + } + + public function aNullableConstOrEnumNeverWidensToNull(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `nullable: true` beside a `const` the document wrote: the value is + // the constant, never null. + foreach (range(1, 20) as $seed) { + Assert::same($compiler->compile(['type' => 'string', 'nullable' => true, 'const' => null])->generate(new Random($seed))->value, null); + Assert::same($compiler->compile(['const' => 7, 'nullable' => true])->generate(new Random($seed))->value, 7); + } + + // An enum that lists null selects it; one that does not, does not. + $openEnum = $compiler->compile(['type' => 'string', 'nullable' => true, 'enum' => ['a', 'b']]); + foreach (range(1, 20) as $seed) { + Assert::true(in_array($openEnum->generate(new Random($seed))->value, ['a', 'b'], strict: true)); + } + + // A bare `nullable` with no enum or const is the only shape that + // reaches `Gen::nullable()`. + $bare = $compiler->compile(['type' => 'integer', 'minimum' => 3, 'maximum' => 5, 'nullable' => true]); + $values = []; + foreach (range(1, 40) as $seed) { + $values[] = $bare->generate(new Random($seed))->value; + } + Assert::true(in_array(null, $values, strict: true)); + foreach ($values as $value) { + Assert::true($value === null || (is_int($value) && $value >= 3 && $value <= 5)); + } + } + + public function aDependentSchemaUnderADirectionRefusesAForeignProperty(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['type' => 'string'], + ], + 'dependentSchemas' => ['kind' => ['required' => ['detail']]], + ]; + $withFlag = [...$schema, 'properties' => ['kind' => ['type' => 'string'], 'detail' => ['type' => 'string', 'writeOnly' => true]]]; + + foreach ([SchemaDirection::Request, SchemaDirection::Response] as $direction) { + $compiler = new SchemaArbitraryCompiler(direction: $direction); + Expect::exception(UnsupportedGeneration::class); + $compiler->compile($withFlag); + } + + // The same schema without the foreign-direction property generates in + // both directions, so the refusal above is about the flag and not + // about the direction itself. + foreach ([ + ['type' => 'object', 'dependentRequired' => ['missing' => []]], + $schema, + ] as $case) { + $values = Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($case), count: 10, seed: 97); + Assert::same(count($values), 10); + } + } + + public function containsWithNoWitnessIsStillGeneratedWhenMinContainsIsZero(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `minContains: 0` lets the empty array through, which a positive + // `minContains` refuses; `contains: false` with it admits nothing else. + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 0, + 'maxItems' => 3, + ]; + $lengths = []; + foreach (Gen::sample($compiler->compile($schema), count: 30, seed: 101) as $value) { + Assert::true(is_array($value)); + $lengths[count($value)] = true; + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 5); + } + } + Assert::true(array_key_exists(0, $lengths), 'minContains: 0 never produced the empty array'); + + // `items: false` alongside `contains` closes the array at length 0. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => false, + 'contains' => ['type' => 'integer'], + 'minContains' => 0, + ]), count: 5, seed: 103) as $value) { + Assert::same($value, []); + } + + // A tail whose type contradicts `contains` is refused outright, rather + // than silently generating an array that satisfies neither. + Expect::exception(UnsupportedGeneration::class); + $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'string'], + ]); + + // `contains: false` with `items: false` is the same closed tuple. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => false, + 'contains' => false, + ]), count: 5, seed: 107) as $value) { + Assert::same($value, []); + } + } + + public function aForeignDirectionPropertyIsNeverSentButKeepsItsName(): void + { + // A request never carries a `readOnly` member and a response never + // carries a `writeOnly` one — each is the flag the other direction + // owns. + $schema = [ + 'type' => 'object', + 'required' => ['id', 'computed'], + 'properties' => [ + 'id' => ['type' => 'integer'], + 'computed' => ['type' => 'string', 'readOnly' => true], + ], + 'additionalProperties' => false, + ]; + + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile($schema), count: 40, seed: 109) as $value) { + Assert::true(is_array($value) && array_key_exists('id', $value)); + Assert::false(array_key_exists('computed', $value)); + } + + // The mirror image: the same document under the response direction, + // where `computed` is now the member that travels. + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($schema), count: 40, seed: 113) as $value) { + Assert::true(is_array($value) && array_key_exists('computed', $value)); + } + + // And a `writeOnly` member the other way round. + $secret = [ + 'type' => 'object', + 'required' => ['id', 'secret'], + 'properties' => [ + 'id' => ['type' => 'integer'], + 'secret' => ['type' => 'string', 'writeOnly' => true], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($secret), count: 40, seed: 127) as $value) { + Assert::true(is_array($value) && array_key_exists('id', $value)); + Assert::false(array_key_exists('secret', $value)); + } + + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile($secret), count: 20, seed: 131) as $value) { + Assert::true(is_array($value) && array_key_exists('secret', $value)); + } + } + + public function propertyNamesMustAcceptEveryDeclaredProperty(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // A pattern that admits every declared name generates; one that + // excludes a declared name is refused rather than dropping it. + $admitting = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer'], 'tag' => ['type' => 'string']], + 'propertyNames' => ['pattern' => '^[a-z]+$'], + 'additionalProperties' => ['type' => 'boolean'], + ]; + foreach (Gen::sample($compiler->compile($admitting), count: 20, seed: 131) as $value) { + Assert::true(is_array($value)); + foreach (array_keys($value) as $name) { + Assert::true(is_string($name) && preg_match('~^[a-z]+$~', $name) === 1); + } + } + + foreach ([ + ['pattern' => '^id$'], + ['pattern' => 42], + ['pattern' => '([a-z'], + ] as $propertyNames) { + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'tag' => ['type' => 'string']], 'propertyNames' => $propertyNames]); + } + + // A `propertyNames` with no `pattern` constrains nothing. + $unconstrained = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'propertyNames' => ['maxLength' => 4], + 'additionalProperties' => ['type' => 'string', 'maxLength' => 2], + ]; + foreach (Gen::sample($compiler->compile($unconstrained), count: 20, seed: 137) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'id') { + Assert::true(is_string($member) && mb_strlen($member) <= 2); + } + } + } + } + + public function uniqueItemsSeesTheExclusiveBoundsOfANumericItemDomain(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `0..1` with both bounds exclusive leaves exactly one value, so + // `minItems: 2` is refused for want of a domain rather than left to + // exhaust at run time (#123). With the bounds inclusive it holds two. + foreach ([ + ['type' => 'integer', 'minimum' => 0, 'maximum' => 1, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + ['type' => 'integer', 'minimum' => 0, 'maximum' => 3, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + ] as $index) { + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'array', 'items' => $index, 'uniqueItems' => true, 'minItems' => 2]); + } + + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 1, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + 'uniqueItems' => true, + 'minItems' => 1, + 'maxItems' => 1, + ]), count: 10, seed: 139) as $value) { + Assert::same($value, [1]); + } + + // A one-value domain is `const`, a two-value one an `enum`, and a + // `boolean` a pair: each is knowable from the shape alone. + foreach ([ + [['const' => 'x'], ['x']], + [['enum' => ['x', 'y']], ['x', 'y']], + [['type' => 'boolean'], [true, false]], + ] as [$item, $expected]) { + Assert::same( + Gen::sample($compiler->compile(['type' => 'array', 'items' => $item, 'uniqueItems' => true, 'minItems' => 2, 'maxItems' => 2]), count: 10, seed: 149), + array_fill(0, 10, $expected), + ); + } + + // A `null` domain holds exactly one value. + foreach (Gen::sample($compiler->compile(['type' => 'array', 'items' => ['type' => 'null'], 'uniqueItems' => true, 'minItems' => 1, 'maxItems' => 1]), count: 5, seed: 151) as $value) { + Assert::same($value, [null]); + } + } + + public function containsIntersectsTheTailWithTheMembershipSchema(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Every emitted element answers both `items` and `contains`, which is + // the intersection the compiler builds rather than either alone. + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 10], + 'minContains' => 2, + 'maxItems' => 3, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 157) as $value) { + Assert::true(is_array($value) && count($value) >= 2); + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 10); + } + } + + // A `contains` with no `items` narrows nothing: the tail is whatever + // an open array carries. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'contains' => ['type' => 'integer'], + 'minContains' => 1, + 'maxItems' => 2, + ]), count: 20, seed: 163) as $value) { + Assert::true(is_array($value) && count($value) >= 1); + foreach ($value as $item) { + Assert::true(is_int($item)); + } + } + } + + public function aPatternNameIsToldApartFromAFreeOne(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two patterns that a generated name may match either of: the value + // under the name answers the schema of every pattern it matched. + $schema = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'patternProperties' => [ + '^a' => ['type' => 'string', 'maxLength' => 3], + 'z$' => ['type' => 'boolean'], + ], + 'additionalProperties' => false, + ]; + $check = new SchemaCheck(); + $both = 0; + foreach (Gen::sample($compiler->compile($schema), count: 100, seed: 167) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name === 'id') { + Assert::true(is_int($member)); + + continue; + } + $matchesA = str_starts_with((string) $name, 'a'); + $matchesZ = str_ends_with((string) $name, 'z'); + Assert::true($matchesA || $matchesZ); + if ($matchesA && $matchesZ) { + Assert::true(is_string($member) || is_bool($member)); + ++$both; + } elseif ($matchesA) { + Assert::true(is_string($member) && mb_strlen($member) <= 3); + } else { + Assert::true(is_bool($member)); + } + } + } + Assert::true($check->accepts((object) [], $schema, SchemaDialect::OpenApi31)); + + // A name the pattern matches answers the pattern, never + // `additionalProperties` — the two declarations are not alternatives + // between which a name is chosen. + $patternWins = [ + 'type' => 'object', + 'patternProperties' => ['^p_' => ['type' => 'string', 'maxLength' => 4]], + 'additionalProperties' => ['type' => 'integer'], + ]; + foreach (Gen::sample($compiler->compile($patternWins), count: 40, seed: 173) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if (str_starts_with((string) $name, 'p_')) { + Assert::true(is_string($member) && mb_strlen($member) <= 4); + } else { + Assert::true(is_int($member)); + } + } + } + + Assert::true($check->accepts((object) [], $patternWins, SchemaDialect::OpenApi31)); + + // A `patternProperties` entry that is not a named schema object is + // refused before any name is drawn. + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'object', 'patternProperties' => ['^a' => ['string']]]); + } + + public function everyMemberAnswersTheSchemaOfItsOwnName(): void + { + $compiler = new SchemaArbitraryCompiler(); + $check = new SchemaCheck(); + + // The value a member carries is compiled against the name it lands + // under. Drawing the value once for the whole map handed every name + // the schema of whichever name was drawn first, and two thirds of the + // objects that reached the contract failed validation. + $schema = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'patternProperties' => [ + '^a' => ['type' => 'string', 'maxLength' => 3], + 'z$' => ['type' => 'boolean'], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 300, seed: 5) as $value) { + Assert::true($check->accepts((object) $value, $schema, SchemaDialect::OpenApi31)); + } + + // The same holds when the free member and the patterned ones declare + // different types, and for a name several patterns match: it answers + // all of them at once. + foreach ([ + [ + 'type' => 'object', + 'patternProperties' => ['^p_' => ['type' => 'string', 'maxLength' => 4]], + 'additionalProperties' => ['type' => 'integer'], + ], + [ + 'type' => 'object', + 'patternProperties' => [ + '^both' => ['type' => 'string'], + 'both$' => ['type' => 'string', 'maxLength' => 2], + ], + 'additionalProperties' => false, + ], + ] as $case) { + foreach (Gen::sample($compiler->compile($case), count: 200, seed: 7) as $value) { + Assert::true($check->accepts((object) $value, $case, SchemaDialect::OpenApi31)); + } + } + } + + public function everyRefusalMessageIsTheOneTheCallerIsShown(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `propertyNames` is a filter on the names an object may carry, and a + // declared one it filters out is refused rather than silently dropped. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: propertyNames excludes a declared property'); + $compiler->compile([ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer'], 'Tag' => ['type' => 'string']], + 'propertyNames' => ['pattern' => '^[a-z]+$'], + ]); + } + + public function aDependentBudgetOverflowIsRefusedByName(): void + { + // The enumeration has a budget, and a document past it is told so + // rather than generating an exponential number of combinations. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: dependent keywords exceed the eight-trigger generation budget'); + (new SchemaArbitraryCompiler())->compile([ + 'type' => 'object', + 'properties' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], ['type' => 'string']), + 'dependentRequired' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], []), + ]); + } + + public function aForeignDirectionMemberUnderADependentKeywordIsRefusedByName(): void + { + // A dependent keyword beside a property the other direction owns has + // no constructible answer: the member is never sent, and the trigger + // it keys on can therefore never be there. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: dependent keywords with foreign-direction properties are outside the constructive subset'); + (new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile([ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['type' => 'string', 'readOnly' => true], + ], + 'dependentSchemas' => ['kind' => ['required' => ['detail']]], + ]); + } + + /** + * Each half of a guard chain is a refusal the caller can hit on its own, + * so each is provoked on its own: a chain tested only through one of its + * branches cannot tell the branches apart. + * + * @param array $schema + */ + #[DataProvider('refusedShapes')] + public function aRefusedShapeIsRefusedWhateverElseItCarries(array $schema, string $refusal): void + { + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: ' . $refusal); + (new SchemaArbitraryCompiler())->compile($schema); + } + + /** @return iterable, string}> */ + public static function refusedShapes(): iterable + { + // An unevaluated keyword beside a conditional or a union is outside + // the constructive subset — one refusal, six ways to ask for it. + foreach (['unevaluatedProperties' => ['type' => 'object'], 'unevaluatedItems' => ['type' => 'array']] as $keyword => $subschema) { + foreach (['if' => true, 'anyOf' => [[]], 'oneOf' => [[]]] as $applicator => $value) { + yield $keyword . ' beside ' . $applicator => [ + ['type' => $keyword === 'unevaluatedItems' ? 'array' : 'object'] + [$keyword => $subschema, $applicator => $value], + 'unevaluated keywords with conditional or union applicators are outside the constructive subset', + ]; + } + } + + // A dependent keyword needs a plain object: each of the six ways of + // not being one is refused by the same message. + foreach (['patternProperties' => ['^a' => ['type' => 'string']], 'if' => true, 'allOf' => [[]], 'anyOf' => [[]], 'oneOf' => [[]]] as $keyword => $value) { + yield 'dependentRequired beside ' . $keyword => [ + ['type' => 'object', 'properties' => ['a' => ['type' => 'string']], 'dependentRequired' => ['a' => []], $keyword => $value], + 'dependent keywords require a plain object schema', + ]; + } + yield 'dependentRequired on a non-object' => [ + ['type' => 'string', 'dependentRequired' => ['a' => []]], + 'dependent keywords require a plain object schema', + ]; + } + + /** + * A schema may name no type at all and still say what it is: each of + * these keywords answers on its own, and a document carrying one is + * generated as the type it implies. + * + * @param array $schema + */ + #[DataProvider('typeInference')] + public function aStructuralKeywordIsEnoughToSayWhatTheSchemaIs(array $schema, string $expected): void + { + foreach (Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 20, seed: 179) as $value) { + Assert::true(is_array($value)); + Assert::true($expected === 'object' ? !array_is_list($value) || $value === [] : array_is_list($value)); + } + } + + /** @return iterable, string}> */ + public static function typeInference(): iterable + { + // A dependent keyword needs its trigger declared, so the object these + // produce is carried by `properties` and it is `dependentRequired` + // alone that answers the question of what type the schema is. + yield 'dependentRequired' => [[ + 'properties' => ['a' => ['type' => 'integer']], + 'dependentRequired' => ['a' => []], + ], 'object']; + yield 'dependentSchemas' => [[ + 'properties' => ['a' => ['type' => 'integer']], + 'dependentSchemas' => ['a' => ['maxProperties' => 1]], + ], 'object']; + yield 'unevaluatedProperties' => [['unevaluatedProperties' => true], 'object']; + yield 'properties' => [['properties' => ['id' => ['type' => 'integer']]], 'object']; + yield 'items' => [['items' => ['type' => 'integer']], 'array']; + yield 'prefixItems' => [['prefixItems' => [['type' => 'integer']]], 'array']; + yield 'contains' => [['contains' => ['type' => 'integer']], 'array']; + + // Each is asked for alone: one keyword of the pair would answer just + // as well, and the pair would then prove nothing about the other. + yield 'dependentRequired with a declared trigger' => [[ + 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']], + 'dependentRequired' => ['a' => ['b']], + ], 'object']; + } } From 1862e421bd208280768908969d0c344dac4b79e9 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:24:02 +0300 Subject: [PATCH 4/9] Return the assembled object directly (#157) --- src/Internal/Compile/ContainerArbitraries.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 484b516..7356310 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -503,7 +503,7 @@ private function additionalProperties( }, ); - $result = Gen::map($extras, static function (array $extra) use ($values): array { + return Gen::map($extras, static function (array $extra) use ($values): array { $typed = []; /** @var array{0: string, 1: mixed} $member */ foreach ($extra as $member) { @@ -512,8 +512,6 @@ private function additionalProperties( return array_replace($values, $typed); }); - - return $result; } /** From 85e85287ff7fbc5ed083ba976afa78b2bb30e76b Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:35:09 +0300 Subject: [PATCH 5/9] Pin the allOf and unevaluated interaction by its answers (#157) --- tests/SchemaArbitraryCompilerTest.php | 87 +++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index a1547e3..23c009b 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -2750,6 +2750,93 @@ public function everyMemberAnswersTheSchemaOfItsOwnName(): void } } + public function anUnevaluatedKeywordClosesAnAllOfBranchItCannotSee(): void + { + $compiler = new SchemaArbitraryCompiler(); + $check = new SchemaCheck(); + + // `unevaluatedProperties` beside `allOf` closes the object: the merge + // has to carry it as a branch of its own, since a member no declared + // property answers is what it forbids. + $object = [ + 'allOf' => [ + ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'required' => ['id']], + ], + 'unevaluatedProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($object), count: 40, seed: 181) as $value) { + Assert::same(array_keys((array) $value), ['id']); + Assert::true($check->accepts((object) $value, $object, SchemaDialect::OpenApi31)); + } + + $array = [ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer']], 'minItems' => 1], + ], + 'unevaluatedItems' => false, + ]; + foreach (Gen::sample($compiler->compile($array), count: 40, seed: 181) as $value) { + Assert::same($value, [$value[0]]); + Assert::true($check->accepts($value, $array, SchemaDialect::OpenApi31)); + } + + // Without the keyword the same object admits an undeclared member, so + // the two runs differ in the closure alone and not in anything the + // merge decided. + $open = [ + 'allOf' => [ + ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'minProperties' => 2], + ], + ]; + $extras = 0; + foreach (Gen::sample($compiler->compile($open), count: 60, seed: 181) as $value) { + $extras += count($value) - 1; + } + Assert::true($extras > 0, 'a closed object was generated as an open one'); + + // An `allOf` sibling is refused by name: a member the branches do not + // mention cannot be closed after the fact. + foreach (['properties' => ['id' => ['type' => 'integer']], 'type' => 'object'] as $keyword => $value) { + Expect::exception(UnsupportedGeneration::class) + ->withMessage(sprintf('Unsupported OpenAPI schema generation: allOf with sibling keyword "%s" is outside the supported subset', $keyword)); + $compiler->compile(['allOf' => [['type' => 'object']], $keyword => $value, 'description' => 'annotated']); + } + + // A branch that closes the object on its own is refused too — the + // merge would have to decide which of the two closures wins. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: unevaluated keywords inside allOf branches are outside the constructive subset'); + $compiler->compile(['allOf' => [['unevaluatedProperties' => false]], 'unevaluatedProperties' => false]); + } + + public function anAllOfCannotMoveItemsAcrossASiblingTuple(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two branches disagreeing about where the tuple ends cannot be + // merged into one array: the head of one is not the head of the other. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: allOf items cannot be moved across sibling prefixItems'); + $compiler->compile([ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer'], ['type' => 'string']]], + ['items' => false], + ], + ]); + + // Branches that agree keep the tuple, and the merged array answers it. + $check = new SchemaCheck(); + $schema = [ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer'], ['type' => 'string']], 'minItems' => 2, 'maxItems' => 2, 'unevaluatedItems' => false], + ], + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 191) as $value) { + Assert::same($value, [$value[0], $value[1]]); + Assert::true($check->accepts($value, $schema, SchemaDialect::OpenApi31)); + } + } + public function everyRefusalMessageIsTheOneTheCallerIsShown(): void { $compiler = new SchemaArbitraryCompiler(); From 8d48d03a252cfc11abf8e8cb6ed5a5448826b84b Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:42:18 +0300 Subject: [PATCH 6/9] Resolve a local reference by the shape it names (#157) --- tests/ResponseMaterializerTest.php | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index 5102074..736c02e 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -12,6 +12,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\ResponseContracts; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Expect; use Testo\Test; @@ -289,6 +290,70 @@ public function encodesTheTailOfAPrefixTupleAndNestedApplicators(): void ); } + public function aLocalReferenceIsResolvedAgainstTheRootDefs(): void + { + $encoder = new JsonBodyEncoder(); + + // The name is JSON-Pointer-escaped: `a/b` is written `a~1b`, and a + // `~` in a name is written `~0`. Both resolve, and the referenced shape + // is what decides the encoding. + Assert::same( + $encoder->encode( + ['k' => []], + [ + '$defs' => ['a/b~c' => ['type' => 'object', 'properties' => ['k' => ['type' => 'object']]]], + '$ref' => '#/$defs/a~1b~0c', + ], + ), + '{"k":{}}', + ); + } + + /** + * A `$ref` the encoder cannot resolve is refused by name: it would + * otherwise encode the value against no schema at all, and a nested empty + * object as `[]` — the exact loss the local form exists to prevent. + * + * @param array $schema + */ + #[DataProvider('unresolvableReferences')] + public function anUnresolvableReferenceIsRefused(array $schema, string $reason): void + { + Expect::exception(InvalidCase::class); + (new JsonBodyEncoder())->encode(['k' => []], [...$schema, 'type' => 'object']); + } + + /** @return iterable, string}> */ + public static function unresolvableReferences(): iterable + { + yield 'a remote pointer' => [['$ref' => 'other.json#/$defs/node'], 'not local']; + yield 'a non-string pointer' => [['$ref' => 42], 'not local']; + yield 'another root' => [['$ref' => '#/definitions/node'], 'not local']; + yield 'an absent member' => [ + ['$defs' => ['node' => ['type' => 'object']], '$ref' => '#/$defs/missing'], + 'names no $defs member', + ]; + yield 'a defs that is a list' => [ + ['$defs' => [['type' => 'object']], '$ref' => '#/$defs/node'], + 'names no $defs member', + ]; + } + + public function aDefsThatIsAListCarriesNoMemberToName(): void + { + foreach ([ + ['$defs' => [['type' => 'object']]], + ['$defs' => 'invalid'], + ] as $schema) { + try { + (new JsonBodyEncoder())->encode(['k' => []], ['type' => 'object', ...$schema]); + Assert::true(actual: false); + } catch (InvalidCase) { + Assert::true(actual: true); + } + } + } + /** * A numeric member name is a name. PHP normalizes the array key `"2020"` * to an integer, and refusing it made a legal document impossible to From 486828bbcd9a963593d85f955844d071d2c94421 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:44:29 +0300 Subject: [PATCH 7/9] Let an applicator branch carry the shape the root omits (#157) --- tests/ResponseMaterializerTest.php | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index 736c02e..fe86fe5 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -354,6 +354,67 @@ public function aDefsThatIsAListCarriesNoMemberToName(): void } } + public function anApplicatorBranchContributesTheShapeTheRootLacks(): void + { + $encoder = new JsonBodyEncoder(); + + // The root says only `allOf`; each of the three shape keywords a + // branch may carry decides the encoding on its own, and asking for one + // proves nothing about the others. + Assert::same( + $encoder->encode(['a', 'b'], ['allOf' => [['type' => 'array', 'items' => ['type' => 'string']]]]), + '["a","b"]', + ); + Assert::same( + $encoder->encode([1, 2], ['allOf' => [['type' => 'array', 'items' => ['type' => 'integer']]]]), + '[1,2]', + ); + Assert::same( + $encoder->encode( + ['first' => [], 'second' => []], + ['allOf' => [['type' => 'object', 'properties' => ['first' => ['type' => 'object'], 'second' => ['type' => 'object']]]]], + ), + '{"first":{},"second":{}}', + ); + + // `prefixItems` decides position by position, so the second element is + // read against the second schema and not against `items`. + Assert::same( + $encoder->encode(['a', ['b' => []]], [ + 'allOf' => [[ + 'type' => 'array', + 'prefixItems' => [['type' => 'string'], ['type' => 'object', 'properties' => ['b' => ['type' => 'object']]]], + ]], + ]), + '["a",{"b":{}}]', + ); + + // A shape the root already declares wins: the branch is not allowed + // to overrule it, so the encoding follows the root. + Assert::same( + $encoder->encode([[]], ['type' => 'array', 'items' => ['type' => 'object'], 'allOf' => [['items' => ['type' => 'string']]]]), + '[{}]', + ); + + // Properties merge rather than replace: a branch that declares one + // member of an object the root declared another keeps both. + Assert::same( + $encoder->encode( + ['kept' => [], 'added' => []], + [ + 'allOf' => [ + ['properties' => ['kept' => ['type' => 'object']]], + ['properties' => ['added' => ['type' => 'object']]], + ], + ], + ), + '{"kept":{},"added":{}}', + ); + + // A branch that is not an object cannot contribute a shape. + Assert::same($encoder->encode(['a', 'b'], ['allOf' => ['ignored', ['type' => 'array', 'items' => ['type' => 'string']]]]), '["a","b"]'); + } + /** * A numeric member name is a name. PHP normalizes the array key `"2020"` * to an integer, and refusing it made a legal document impossible to From 119e9f37642021c56b0e1d3f462bf6b8d95185a7 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:48:17 +0300 Subject: [PATCH 8/9] Keep a not beside an enum from narrowing it to nothing (#157) --- tests/SchemaArbitraryCompilerTest.php | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 23c009b..bfaaf27 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -2837,6 +2837,36 @@ public function anAllOfCannotMoveItemsAcrossASiblingTuple(): void } } + public function aNotLeavesAtLeastOneValueOfTheEnumItNarrows(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two of three values survive, so the filter keeps the schema and the + // generator only ever answers with one of them. + $narrowed = $compiler->compile(['enum' => ['x', 'y', 'z'], 'not' => ['enum' => ['x']]]); + $values = array_unique(Gen::sample($narrowed, count: 40, seed: 193)); + sort($values); + Assert::same($values, ['y', 'z']); + + // The `not` may list more than one value, and an enum whose values are + // written as a map (which JSON forbids, but a hand-built schema can + // carry) is re-indexed before the comparison. + foreach (Gen::sample($compiler->compile(['enum' => ['x', 'y', 'z'], 'not' => ['enum' => ['x', 'y']]]), count: 40, seed: 197) as $value) { + Assert::same($value, 'z'); + } + + // A `not` beside a `const` narrows nothing, so the constant stands. + foreach (range(1, 10) as $seed) { + Assert::same($compiler->compile(['const' => 'x', 'not' => ['const' => 'y']])->generate(new Random($seed))->value, 'x'); + } + + // A `not` that forbids a type the source does not declare at all + // changes nothing, and the values keep their declared type. + foreach (Gen::sample($compiler->compile(['type' => 'integer', 'not' => ['type' => 'boolean']]), count: 20, seed: 199) as $value) { + Assert::true(is_int($value)); + } + } + public function everyRefusalMessageIsTheOneTheCallerIsShown(): void { $compiler = new SchemaArbitraryCompiler(); From 5e79dd4b750d785d9e1471e555acd1ddae96065b Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 22:52:28 +0300 Subject: [PATCH 9/9] Name the refusal an unresolvable reference gets (#157) --- tests/ResponseMaterializerTest.php | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index fe86fe5..760c758 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -319,23 +319,32 @@ public function aLocalReferenceIsResolvedAgainstTheRootDefs(): void #[DataProvider('unresolvableReferences')] public function anUnresolvableReferenceIsRefused(array $schema, string $reason): void { - Expect::exception(InvalidCase::class); + Expect::exception(InvalidCase::class)->withMessage($reason); (new JsonBodyEncoder())->encode(['k' => []], [...$schema, 'type' => 'object']); } /** @return iterable, string}> */ public static function unresolvableReferences(): iterable { - yield 'a remote pointer' => [['$ref' => 'other.json#/$defs/node'], 'not local']; - yield 'a non-string pointer' => [['$ref' => 42], 'not local']; - yield 'another root' => [['$ref' => '#/definitions/node'], 'not local']; + yield 'a remote pointer' => [ + ['$ref' => 'other.json#/$defs/node'], + 'Schema $ref must be a local reference into $defs', + ]; + yield 'a non-string pointer' => [ + ['$ref' => 42], + 'Schema $ref must be a local reference into $defs', + ]; + yield 'another root' => [ + ['$ref' => '#/definitions/node'], + 'Schema $ref must be a local reference into $defs', + ]; yield 'an absent member' => [ ['$defs' => ['node' => ['type' => 'object']], '$ref' => '#/$defs/missing'], - 'names no $defs member', + 'Schema $ref "#/$defs/missing" names no $defs member', ]; yield 'a defs that is a list' => [ ['$defs' => [['type' => 'object']], '$ref' => '#/$defs/node'], - 'names no $defs member', + 'Schema $defs must be an object', ]; }