From 2d54b4d7851cde7dc067fb0a2196107389a067a2 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 19:50:54 +0300 Subject: [PATCH] Ask the contract whether the number branch admits an integer; refuse multiples a double cannot spell; rector on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numberBranchAdmits() held a copy of the float multipleOf rule the contract left in 0.12.1, and the two disagreed on 4670 of the integers in ±100000 for 0.7: the generator kept 58254 on the integer branch, the contract saw it on both. SchemaCheck::isMultipleOf() is the verdict now (#132). A number bound so wide that its decimal multiples need more significant digits than a double holds fails closed at compile time; a multiple that divides one is exempt (#133). static-analysis.yml runs rector on every PR: 0.15.0 shipped with a rule red. --- .github/workflows/static-analysis.yml | 36 +++++++++++++++ AGENTS.md | 11 ++++- CHANGELOG.md | 22 +++++++++ README.md | 4 +- README.ru.md | 4 +- composer.json | 2 +- llms.txt | 5 ++- .../Compile/CompositionArbitraries.php | 12 +++-- src/Internal/Compile/ScalarArbitraries.php | 11 +++++ tests/Support/ZooContracts.php | 12 +++-- tests/WireAgreementTest.php | 45 +++++++++++++++++++ 11 files changed, 147 insertions(+), 17 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 4f37b82..e041ea0 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2f02f7c..ad8a479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 → diff --git a/CHANGELOG.md b/CHANGELOG.md index cb563a4..55cc9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 74a0f79..c0e0739 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/README.ru.md b/README.ru.md index b002023..018746c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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` | diff --git a/composer.json b/composer.json index 6419444..1ee895d 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/llms.txt b/llms.txt index ce5943c..ce521a7 100644 --- a/llms.txt +++ b/llms.txt @@ -29,7 +29,10 @@ Public API: (`... for operation "op", query parameter "limit": `). 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`. diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index 8ac08b3..d30cd65 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -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; @@ -149,14 +150,11 @@ private function numberBranchAdmits(int $value, array $number): bool 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 diff --git a/src/Internal/Compile/ScalarArbitraries.php b/src/Internal/Compile/ScalarArbitraries.php index ac35b1a..3c57439 100644 --- a/src/Internal/Compile/ScalarArbitraries.php +++ b/src/Internal/Compile/ScalarArbitraries.php @@ -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), diff --git a/tests/Support/ZooContracts.php b/tests/Support/ZooContracts.php index a8328bc..daf1e65 100644 --- a/tests/Support/ZooContracts.php +++ b/tests/Support/ZooContracts.php @@ -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' => []], ]], diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index d07f719..0460f6a 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -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; @@ -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; @@ -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 */ + 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