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
36 changes: 36 additions & 0 deletions .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,39 @@ jobs:

- name: Psalm
run: composer psalm

rector:
name: Rector
needs: changes
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run == 'true') }}
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.4'
coverage: none
extensions: json, mbstring
tools: composer:v2

- name: Cache Composer dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.json') }}
restore-keys: composer-${{ runner.os }}-

- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist

# Dry-run only. `composer build` does not run rector and release.yml
# runs nothing: 0.15.0 shipped with a rule red in a test added by its
# last commit, noticed on the next PR. Ten seconds here, every PR.
- name: Rector
run: composer rector
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,16 @@ into the monorepo) plus `git config --global --add safe.directory "*"`.
product is the clean decimal (`round($k * $m, $decimals)`), which is what
the contract judges by since 0.12.1 — on every machine, bcmath or not
(openapi-contract#151). Never emit the float product `64.10000000000001`:
it is not a decimal multiple of `0.1` and the contract says so.
it is not a decimal multiple of `0.1` and the contract says so. A bound
past which the multiples need more significant digits than a double holds
is refused at compile time (#133).
- **Never predict a contract verdict with a copy of its rule.** Whether the
`number` branch of a `oneOf` admits an integer is
`SchemaCheck::isMultipleOf()` (openapi-contract 0.12.2); the float copy
this held disagreed on 4670 integers in ±100000 for `0.7` and was found
by a delta review, not by the zoo (#132). Same shape as the
`DirectionalSchemas` copy before it: when the generator needs a verdict,
ask the contract to export it.
- The end-to-end oracle for the valid phase is `tests/Support/ZooContracts.php`
+ `ContractSuiteTest::zooValidCasesPassTheBuiltInChecks`: one operation per
schema feature, checked through materialize → validate → transport →
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ 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.15.2 — 2026-09-19

- **Fixed.** Whether the `number` branch of a `oneOf` admits an integer was
judged by a copy of the contract's old float `multipleOf` rule; with
0.12.1 the contract judges on the decimals and the two disagreed on 4670
of the integers in ±100000 for `multipleOf: 0.7` — the generator kept
`58254` on the `integer` branch, the contract saw it on both, and a case
called valid was rejected. The verdict is the contract's now:
`SchemaCheck::isMultipleOf()` (openapi-contract 0.12.2, required as
`^0.12.2`). The zoo's `amounts.create` carries `step` (`0.7` beside a wide
integer branch) and the contract's corpus is re-recorded (#132).
- **Fixed.** A `number` schema whose bound is so wide that its decimal
multiples need more significant digits than a double holds
(`|bound| × 10^decimals > 2^53`) fails closed at compile time instead of
rounding onto a neighbouring decimal that is no multiple; a multiple that
divides one (`1`, `0.5`, `0.25`) is exempt, every double that wide being
an integer. A property pins that every generated multiple is one to the
contract (#133).
- **CI.** `static-analysis.yml` runs `composer rector` on every PR: 0.15.0
shipped with a rule red in a test its last commit added, and nothing
before the next PR ran rector.

## 0.15.1 — 2026-09-19

- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.12.1`, which judges
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,13 @@ the same way.
| Keyword | Generation |
|---|---|
| `type` (single or list), `const`, `enum`, `nullable` (OAS 3.0) | supported; a type list is a weighted union |
| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | supported; a fractional bound on an integer rounds inward, an open bound steps to the adjacent double; a float is spelled on the wire as `json_encode` spells it, and a decimal multiple as the decimal it means (`64.1`, never `64.10000000000001`), which is how the contract judges it since 0.12.1 |
| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | supported; a fractional bound on an integer rounds inward, an open bound steps to the adjacent double; a float is spelled on the wire as `json_encode` spells it, and a decimal multiple as the decimal it means (`64.1`, never `64.10000000000001`), which is how the contract judges it since 0.12.1; a bound so wide that the multiples need more significant digits than a double holds fails closed |
| `minLength`, `maxLength` (capped at 64), `pattern` (PCRE subset) | supported |
| `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (annotation) | supported; a length window the format cannot satisfy, or `pattern` combined with an asserted format, fails closed |
| `items`, `minItems`, `maxItems` (capped at 16), `uniqueItems` | supported; `uniqueItems` over a finite item domain smaller than `minItems` fails closed |
| `properties`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; the cardinality is met by construction (an optional past the ceiling is left out, one needed for the floor brought in) |
| `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), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported |
| `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` |

Expand Down
4 changes: 2 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,13 @@ request-направлению схемы — `readOnly`-свойства ухо
| Keyword | Генерация |
|---|---|
| `type` (один или список), `const`, `enum`, `nullable` (OAS 3.0) | поддержано; список типов — взвешенное объединение |
| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | поддержано; дробная граница у integer округляется внутрь, открытая граница отступает на соседний double; float пишется на провод так, как его пишет `json_encode`, а десятичное кратное — как десятичное число, которое оно означает (`64.1`, никогда `64.10000000000001`), — так его судит контракт с 0.12.1 |
| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | поддержано; дробная граница у integer округляется внутрь, открытая граница отступает на соседний double; float пишется на провод так, как его пишет `json_encode`, а десятичное кратное — как десятичное число, которое оно означает (`64.1`, никогда `64.10000000000001`), — так его судит контракт с 0.12.1; граница настолько широкая, что кратным нужно больше значащих цифр, чем держит double, падает fail-closed |
| `minLength`, `maxLength` (не более 64), `pattern` (подмножество PCRE) | поддержано |
| `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (аннотация) | поддержано; окно длины, которое format не может удовлетворить, или `pattern` вместе с проверяемым format падают fail-closed |
| `items`, `minItems`, `maxItems` (не более 16), `uniqueItems` | поддержано; `uniqueItems` над конечным доменом элементов меньше `minItems` падает fail-closed |
| `properties`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; кардинальность выполняется по построению (optional сверх потолка выпадает, нужный для пола — добавляется) |
| `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению |
| `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано |
| `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` |

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.1",
"rasuvaeff/openapi-contract": "^0.12.2",
"rasuvaeff/property-testing-core": "^0.10 || ^0.11"
},
"require-dev": {
Expand Down
5 changes: 4 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ Public API:
(`... for operation "op", query parameter "limit": <reason>`). Fractional
integer bounds round inward; an open number bound steps to the adjacent
double; a float is spelled as `json_encode` spells it and a decimal
multiple as the decimal it means (`64.1`, never `64.10000000000001`). Object cardinality is met by
multiple as the decimal it means (`64.1`, never `64.10000000000001`); a
bound past which the multiples need more digits than a double holds fails
closed. Whether the `number` branch of a `oneOf` admits an integer is the
contract's `SchemaCheck::isMultipleOf()`, never a copy of it. Object cardinality is met by
construction; `oneOf` over one `integer` and one `number` branch keeps a
value only when exactly one branch admits it. Header enum members keep an
interior space and obs-text; a query `+` stays `%2B` under `allowReserved`.
Expand Down
12 changes: 5 additions & 7 deletions src/Internal/Compile/CompositionArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile;

use Rasuvaeff\OpenApiContract\SchemaCheck;
use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\GenerationExhaustedException;
Expand Down Expand Up @@ -51,7 +52,7 @@

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

Check warning on line 55 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) { - $pairs[] = [1, $this->compiler->compile($branch)]; + $pairs[] = [2, $this->compiler->compile($branch)]; } return Gen::frequency($pairs);
}

return Gen::frequency($pairs);
Expand Down Expand Up @@ -149,14 +150,11 @@
return false;
}
$multiple = $this->positiveNumber($number['multipleOf'] ?? null);
if (is_int($multiple)) {
return $value % $multiple === 0;
}
if (is_float($multiple)) {
return abs((float) $value - round((float) $value / $multiple) * $multiple) < 1e-14;
}

return true;
// The contract's own verdict, never a copy of it: the copy this held
// was the float rule the contract left in 0.12.1, and it disagreed
// on 4670 of the integers in ±100000 for `0.7` (#132).
return $multiple === null || SchemaCheck::isMultipleOf($value, $multiple);
}

private function positiveNumber(mixed $value): int|float|null
Expand Down
11 changes: 11 additions & 0 deletions src/Internal/Compile/ScalarArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,17 @@ public function number(array $schema): ArbitraryInterface
}

$decimals = $this->decimals((float) $multiple);
// `round($k * $m, $decimals)` is the decimal multiple it means only
// while `$k * $m` needs no more significant digits than a double
// holds; past 2^53 / 10^decimals the rounding can land on a
// neighbouring decimal that is no multiple, and the contract says so.
// Refused here rather than discovered on a rejected case (#133). A
// multiple that divides one (`1`, `0.5`, `0.25`) is exempt: every
// double past 2^53 is an integer, and an integer is a multiple of it.
$reach = max(abs($min), abs($max));
if (fmod(1.0, (float) $multiple) !== 0.0 && $reach * (float) (10 ** $decimals) > (float) (2 ** 53)) {
throw UnsupportedGeneration::forSchema(sprintf('number multipleOf %s cannot be spelled exactly up to %s: the multiples need more than the %d significant digits a double holds', json_encode($multiple, JSON_THROW_ON_ERROR), json_encode($reach, JSON_THROW_ON_ERROR), PHP_FLOAT_DIG));
}

return Gen::map(
Gen::intBetween($first, $last),
Expand Down
12 changes: 9 additions & 3 deletions tests/Support/ZooContracts.php
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,19 @@ public static function document(): array
'responses' => ['204' => []],
]],
// Every integer is also a number: a value is valid for the
// union only when exactly one branch admits it (#121).
// union only when exactly one branch admits it (#121) — and
// whether the number branch admits an integer is the
// contract's verdict, not a copy of it: `0.7` over a wide
// integer branch is where the copy disagreed (#132).
'/amounts' => ['post' => [
'operationId' => 'amounts.create',
'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['amount'],
'properties' => ['amount' => ['oneOf' => [['type' => 'integer', 'minimum' => -9, 'maximum' => 9], ['type' => 'number', 'minimum' => 0, 'maximum' => 9, 'multipleOf' => 0.5]]]],
'required' => ['amount', 'step'],
'properties' => [
'amount' => ['oneOf' => [['type' => 'integer', 'minimum' => -9, 'maximum' => 9], ['type' => 'number', 'minimum' => 0, 'maximum' => 9, 'multipleOf' => 0.5]]],
'step' => ['oneOf' => [['type' => 'integer', 'minimum' => -100000, 'maximum' => 100000], ['type' => 'number', 'minimum' => 0, 'maximum' => 100000, 'multipleOf' => 0.7]]],
],
]]]],
'responses' => ['204' => []],
]],
Expand Down
45 changes: 45 additions & 0 deletions tests/WireAgreementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use Nyholm\Psr7\Factory\Psr17Factory;
use Rasuvaeff\OpenApiContract\Contract;
use Rasuvaeff\OpenApiContract\SchemaCheck;
use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries;
use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ContainerArbitraries;
use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ScalarArbitraries;
Expand All @@ -16,6 +19,7 @@
use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer;
use Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler;
use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration;
use Rasuvaeff\PropertyTesting\Property;
use Rasuvaeff\PropertyTesting\Random;
use Testo\Assert;
use Testo\Codecov\Covers;
Expand Down Expand Up @@ -190,6 +194,47 @@ public function oneOfOverIntegerAndNumberIsNotTreatedAsDisjoint(): void
// The number branch refuses the negative integers, so those stay valid
// for the integer branch; 0..5 are admitted by both and never drawn.
Assert::same(array_keys($kinds), ['float', 'negative int']);

// `0.7` over a wide integer branch: the old float copy of the verdict
// kept 58254 on the integer branch and the contract rejected the case
// (#132). The verdict is the contract's now.
$kinds = [];
foreach ($this->validCases($this->jsonBodyContract(['oneOf' => [['type' => 'integer', 'minimum' => -100000, 'maximum' => 100000], ['type' => 'number', 'minimum' => 0, 'maximum' => 100000, 'multipleOf' => 0.7]]]), 'things.create', 300) as $case) {
$value = $case['body']['value'] ?? null;
$kinds[is_int($value) ? ($value < 0 ? 'negative int' : 'int') : 'float'] = true;
}
ksort($kinds);
Assert::same(array_keys($kinds), ['float', 'int', 'negative int']);
}

/**
* A generated decimal multiple is the decimal multiple the contract judges
* it to be — for every index in the range and every multiple a document
* spells (#133).
*/
#[Property(runs: 300)]
public function everyGeneratedMultipleIsOneToTheContract(float $multiple, int $maximum): void
{
$schema = ['type' => 'number', 'minimum' => -$maximum, 'maximum' => $maximum, 'multipleOf' => $multiple];
foreach (Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 20, seed: $maximum) as $value) {
Assert::true(is_float($value) && SchemaCheck::isMultipleOf($value, $multiple), sprintf('%s is a multiple of %s', json_encode($value), json_encode($multiple)));
}
}

/** @return array<string, ArbitraryInterface> */
public static function everyGeneratedMultipleIsOneToTheContractGenerators(): array
{
return [
'multiple' => Gen::elements([0.1, 0.3, 0.7, 0.07, 0.001, 2.5, 0.25, 0.125, 1.5]),
'maximum' => Gen::intBetween(1, 1_000_000_000),
];
}

public function aMultipleTheDoubleCannotSpellUpToTheBoundIsRefused(): void
{
Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.list", query parameter "v": number multipleOf 0.001 cannot be spelled exactly up to 100000000000000: the multiples need more than the 15 significant digits a double holds');

(new RequestCaseArbitrary())->forOperation($this->parameterContract([['name' => 'v', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'number', 'multipleOf' => 0.001, 'minimum' => 0, 'maximum' => 1e14]]])->operation('things.list'));
}

public function oneOfOverIntegerAndNumberFailsClosedWhenNoValueCanBeKeptApart(): void
Expand Down
Loading