Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.16.0 — 2026-09-20

- **Added.** A schema that refers to itself — a tree whose `children` are
trees — is generated. `rasuvaeff/openapi-contract` 0.13 compiles such a
schema to `$defs` plus a local `{$ref: '#/$defs/<name>'}` for every
reference back into the cycle; the generator unfolds the def three levels
below the root and ends at a leaf where the recursive member is left out —
an optional property omitted, an array that may be empty left empty, a
branch of `anyOf`/`oneOf` skipped, a member of an `allOf` inlined as the
def's body. A def whose only way down is a required member that is the def
itself has no finite instance and is refused (`recursive schema "…" has no
finite instance`), as is a def that is an `allOf` member of itself; a
`$ref` to anything but the schema's own `$defs` is refused by name. The
JSON body encoder follows the same references, so a nested empty object
three levels down is still written as `{}`. The zoo gains `tree.create`;
the recorded corpus grows by its 25 cases.
- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.13`.

## 0.15.2 — 2026-09-19

- **Fixed.** Whether the `number` branch of a `oneOf` admits an integer was
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ before it reaches a transport.

- PHP 8.3 – 8.5
- `ext-mbstring`
- `rasuvaeff/openapi-contract` ^0.12.1 and `rasuvaeff/property-testing-core` ^0.10–^0.11
- `rasuvaeff/openapi-contract` ^0.13 and `rasuvaeff/property-testing-core` ^0.10–^0.11
- `psr/http-message`, `psr/http-factory` and `psr/http-server-handler`
implementations — a PSR-17 factory materializes requests, and `ContractSuite`
drives a PSR-15 handler in process
Expand Down Expand Up @@ -137,7 +137,8 @@ the same way.
| `readOnly` (requests), `writeOnly` (responses) | dropped per direction |
| `anyOf`, `oneOf` (provably disjoint branches, or one `integer` beside one `number` branch: a value is kept only when exactly one admits it, the number branch's `multipleOf` judged by the contract's own `SchemaCheck::isMultipleOf()`), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported |
| `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed |
| `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` |
| `$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`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` |

Every unsatisfiable combination the compiler can recognise is refused at
compile time; what remains probabilistic (a `pattern` that rarely matches its
Expand Down
5 changes: 3 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ styles и JSON, form-urlencoded или multipart request body, после чег

- PHP 8.3 – 8.5
- `ext-mbstring`
- `rasuvaeff/openapi-contract` ^0.12.1 и `rasuvaeff/property-testing-core` ^0.10–^0.11
- `rasuvaeff/openapi-contract` ^0.13 и `rasuvaeff/property-testing-core` ^0.10–^0.11
- реализации `psr/http-message`, `psr/http-factory` и
`psr/http-server-handler`: PSR-17 factory материализует запросы, а
`ContractSuite` гоняет PSR-15 handler в процессе
Expand Down Expand Up @@ -135,7 +135,8 @@ request-направлению схемы — `readOnly`-свойства ухо
| `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению |
| `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна, а `multipleOf` number-ветви судит собственный `SchemaCheck::isMultipleOf()` контракта), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано |
| `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed |
| `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` |
| `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed |
| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` |

Каждая невыполнимая комбинация, которую компилятор способен распознать,
отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0",
"psr/http-server-handler": "^1.0",
"rasuvaeff/openapi-contract": "^0.12.2",
"rasuvaeff/openapi-contract": "^0.13",
"rasuvaeff/property-testing-core": "^0.10 || ^0.11"
},
"require-dev": {
Expand Down
7 changes: 7 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,10 @@ Object generation supports bounded `minProperties`, `maxProperties`, and
boolean or schema-valued `additionalProperties`.
The constructive `not` subset supports `const`, `enum`, and `type` exclusions;
other negative assertions fail closed.
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
(optional property omitted, array that may be empty left empty, 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; so does a `$ref` to
anything but the schema's own `$defs`.
40 changes: 38 additions & 2 deletions src/Internal/Compile/CompositionArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
public function __construct(
private SchemaArbitraryCompiler $compiler,
private SchemaFacts $facts,
private Definitions $definitions,
) {}

/** @param array<string, mixed> $schema */
Expand All @@ -39,7 +40,9 @@
$schemas = $this->schemaBranches($schema[$keyword], $keyword);

if ($keyword === 'allOf') {
return $this->compiler->compile($this->mergeAllOf($schemas));
[$schemas, $inlined] = $this->inlineReferences($schemas);

return $this->definitions->inlining($inlined, fn(): ArbitraryInterface => $this->compiler->compile($this->mergeAllOf($schemas)));
}
if ($keyword === 'oneOf' && !$this->areDisjoint($schemas)) {
$numeric = $this->integerAndNumberBranches($schemas);
Expand All @@ -52,7 +55,15 @@

$pairs = [];
foreach ($schemas as $branch) {
$pairs[] = [1, $this->compiler->compile($branch)];
try {
$pairs[] = [1, $this->compiler->compile($branch)];

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

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ $pairs = []; foreach ($schemas as $branch) { try { - $pairs[] = [1, $this->compiler->compile($branch)]; + $pairs[] = [2, $this->compiler->compile($branch)]; } catch (Unproducible) { // The leaf of a recursive def: the branch that would hold // more of it is skipped, the others still choose.
} catch (Unproducible) {
// The leaf of a recursive def: the branch that would hold
// more of it is skipped, the others still choose.
}
}
if ($pairs === []) {
throw new Unproducible($keyword);
}

return Gen::frequency($pairs);
Expand Down Expand Up @@ -449,6 +460,31 @@
return $schemas;
}

/**
* A member of a conjunction that is a reference into the schema's
* `$defs` — the contract's form for a 3.1 `$ref` with asserting siblings
* — is merged as the def's body: the conjunction is what the generated
* value has to satisfy, and a reference to a def is that def. The names
* inlined are reported so a body that puts itself into its own
* conjunction is refused rather than inlined without end.
*
* @param list<array<string, mixed>> $branches
* @return array{list<array<string, mixed>>, list<string>}
*/
private function inlineReferences(array $branches): array
{
$inlined = [];
foreach ($branches as $index => $branch) {
if (array_key_exists('$ref', $branch)) {
[$name, $body] = $this->definitions->target($branch['$ref']);
$branches[$index] = $body;
$inlined[] = $name;
}
}

return [$branches, $inlined];
}

/**
* Whether no value can satisfy two of the branches, knowable from their
* declared types alone. `integer` and `number` are one class here: every
Expand Down
40 changes: 38 additions & 2 deletions src/Internal/Compile/ContainerArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,17 @@ public function array(array $schema): ArbitraryInterface
throw UnsupportedGeneration::forSchema('minItems exceeds maxItems or the generation budget');
}

$element = $this->compiler->compile($items);
try {
$element = $this->compiler->compile($items);
} catch (Unproducible $leaf) {
// The leaf of a recursive def: the array that holds no more of
// it ends the recursion — where the schema lets it be empty.
if ($min > 0) {
throw $leaf;
}

return Gen::constant(value: []);
}
if (($schema['uniqueItems'] ?? false) !== true) {
return Gen::arrayOf($element, $min, $max);
}
Expand Down Expand Up @@ -121,7 +131,18 @@ public function object(array $schema): ArbitraryInterface

continue;
}
$compiled = $this->compiler->compile($property);

$compiled = $this->producible($property);
if (!$compiled instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) {
// The leaf of a recursive def: an optional member that would
// hold more of it is left out, a required one gives it up.
if (isset($requiredNames[$name])) {
throw new Unproducible($name);
}
$reserved[$name] = true;

continue;
}
$shape[$name] = isset($requiredNames[$name]) ? $compiled : $this->optionalProperty($compiled);
}
foreach (array_keys($requiredNames) as $name) {
Expand Down Expand Up @@ -242,6 +263,21 @@ private function additionalProperties(
* An optional property carries a value whether or not it is present, so
* the cardinality pass can bring an absent one in without a second draw.
*/
/**
* The member's arbitrary, or `null` where the member is the leaf of a
* recursive def and has no value at this depth.
*
* @param array<string, mixed> $schema
*/
private function producible(array $schema): ?ArbitraryInterface
{
try {
return $this->compiler->compile($schema);
} catch (Unproducible) {
return null;
}
}

private function optionalProperty(ArbitraryInterface $compiled): ArbitraryInterface
{
return Gen::record(['present' => Gen::bool(), 'value' => $compiled]);
Expand Down
159 changes: 159 additions & 0 deletions src/Internal/Compile/Definitions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile;

use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration;

/**
* The `$defs` of the Schema Object being compiled and the bindings a
* recursive def is compiled under.
*
* The contract compiles a schema that refers to itself — a tree whose
* `children` are trees — as `$defs` plus a local `{$ref: '#/$defs/<name>'}`
* for every reference back into the cycle. Generation unfolds that a
* bounded number of levels: a def is compiled once with its own name bound
* to the previous level's arbitrary, and once with the name bound to
* nothing — the leaf, where every reference back to it is unproducible and
* the containers around it leave it out. This is the mutable state that
* threads through the compiler's recursion; the compiler itself stays
* immutable.
*
* @internal
*/
final class Definitions
{
/** @var array<string, array<string, mixed>> */
private array $defs = [];

/**
* A def name bound to an arbitrary while its body is compiled, or to
* `null` while its leaf is: the level below the last one.
*
* @var array<string, ArbitraryInterface|null>
*/
private array $bindings = [];

/** @var array<string, true> defs whose body is being merged into an `allOf` */
private array $merging = [];

/**
* Runs $compile with the `$defs` of a Schema Object root in scope. Roots
* do not nest — a `$defs` map sits on the root the contract emits it on,
* and every reference into it is resolved against that one map.
*
* @template T
*
* @param array<array-key, mixed> $defs
* @param callable(): T $compile
*
* @return T
*/
public function within(array $defs, callable $compile): mixed
{
$outer = $this->defs;
/** @var mixed $body */
foreach ($defs as $name => $body) {
if (!is_array($body) || ($body !== [] && array_is_list($body))) {
throw UnsupportedGeneration::forSchema(sprintf('$defs member "%s" must be a schema object', (string) $name));
}
/** @var array<string, mixed> $body */
$this->defs[(string) $name] = $body;
}

try {
return $compile();
} finally {
$this->defs = $outer;
}
}

/**
* The def a local reference names, by the pointer the contract wrote —
* `#/$defs/components.schemas.Node`, the name JSON-Pointer-escaped.
*
* @return array{string, array<string, mixed>} the name and its body
*/
public function target(mixed $reference): array
{
if (!is_string($reference) || !str_starts_with($reference, '#/$defs/')) {
throw UnsupportedGeneration::forSchema(sprintf(
'$ref "%s" is not a local reference into the schema\'s $defs',
is_string($reference) ? $reference : get_debug_type($reference),
));
}
/** @var non-empty-string $reference */
$name = str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen('#/$defs/')));
if (!array_key_exists($name, $this->defs)) {
throw UnsupportedGeneration::forSchema(sprintf('$ref "%s" names no $defs member', $reference));
}

return [$name, $this->defs[$name]];
}

public function isBound(string $name): bool
{
return array_key_exists($name, $this->bindings);
}

/**
* What a reference to a bound def compiles to: the previous level, or
* nothing at the leaf.
*/
public function bound(string $name): ?ArbitraryInterface
{
return $this->bindings[$name] ?? null;
}

/**
* Runs $compile with $name bound — to the level below, or to nothing.
*
* @template T
*
* @param callable(): T $compile
*
* @return T
*/
public function bind(string $name, ?ArbitraryInterface $level, callable $compile): mixed
{
$this->bindings[$name] = $level;

try {
return $compile();
} finally {
unset($this->bindings[$name]);
}
}

/**
* Runs $compile with $names marked as inlined into an `allOf`, so a body
* that puts itself into its own conjunction is refused rather than
* inlined without end.
*
* @template T
*
* @param list<string> $names
* @param callable(): T $compile
*
* @return T
*/
public function inlining(array $names, callable $compile): mixed
{
foreach ($names as $name) {
if (isset($this->merging[$name])) {
throw UnsupportedGeneration::forSchema(sprintf('$defs member "%s" is an allOf member of itself', $name));
}
$this->merging[$name] = true;
}

try {
return $compile();
} finally {
foreach ($names as $name) {
unset($this->merging[$name]);
}
}
}
}
19 changes: 19 additions & 0 deletions src/Internal/Compile/Unproducible.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile;

/**
* A subschema that has no value at this depth: a reference back to the
* recursive def whose leaf is being compiled. Not a refusal — the container
* around it decides whether it can do without the member (an optional
* property is left out, an array that may be empty is, a nullable value is
* `null`, a branch of `anyOf`/`oneOf` is skipped) or has to give it up in
* turn. Only where the def's leaf itself has no value does it become an
* {@see \Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration}: a
* recursive schema with no finite instance.
*
* @internal
*/
final class Unproducible extends \RuntimeException {}
Loading
Loading