Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ 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
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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, редко
Expand Down
9 changes: 9 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>'}` per reference back; the def unfolds three
levels below the root and ends at a leaf that leaves the recursive member out
Expand Down
22 changes: 21 additions & 1 deletion src/Internal/Compile/CompositionArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
$schemas = $this->schemaBranches($schema[$keyword], $keyword);

if ($keyword === 'allOf') {
foreach ($schemas as $branch) {

Check warning on line 43 in src/Internal/Compile/CompositionArbitraries.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "Foreach_": @@ @@ $schemas = $this->schemaBranches($schema[$keyword], $keyword); if ($keyword === 'allOf') { - foreach ($schemas as $branch) { + foreach ([] 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'); }
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)));
Expand Down Expand Up @@ -340,7 +350,7 @@
/** @param list<array<string, mixed>> $branches
* @return array<string, mixed>
*/
private function mergeAllOf(array $branches): array
public function mergeAllOf(array $branches): array
{
$merged = [];
$required = [];
Expand Down Expand Up @@ -411,6 +421,13 @@
$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;
}
Expand Down Expand Up @@ -525,6 +542,9 @@
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));
}
Expand Down
Loading
Loading