From d045d7afb3ac907740ec68b722c417b6200b9247 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 6 Sep 2026 18:23:00 +0300 Subject: [PATCH] Stop building documents the contract refuses, and watch its master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This suite was green against `rasuvaeff/openapi-contract` 0.7 and red against its development branch: ten tests, all of them building deliberately malformed documents through `Contract::fromArray()` to pin a refusal of *this* package's generator. The contract now refuses those documents where they are written, so the tests died before reaching what they meant to test. The shared multipart fixture is a document that compiles again. The two header declarations it carried to exercise the generator's skip branches — a value that is not an object, a name that is not a string — moved to a test that hands the generator an `Operation` built by hand, which is the only way such a shape can still reach it and therefore the path worth pinning. `rejectsAPartContentTypeMismatchItCannotConstruct` builds its operation the same way, so all fifteen datasets reach the refusal they are named for. The suite is green against both the released contract and its master now, and a `Contract dev-master` job keeps it that way: it runs this suite against that branch on every relevant pull request without gating the build, because the contract's master may legitimately be ahead of what this package supports. The release ritual and the reason for it are in AGENTS.md. Fixes #86 --- .github/workflows/build.yml | 45 +++++++++++++++++++++ AGENTS.md | 25 ++++++++++++ CHANGELOG.md | 18 +++++++++ tests/RequestCaseArbitraryTest.php | 63 +++++++++++++++++++++++++++++- tests/Support/BodyContracts.php | 7 +++- 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c032f1..76097f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -151,6 +151,51 @@ jobs: - name: Build run: composer build + upstream: + name: Contract dev-master + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run == 'true') }} + runs-on: ubuntu-latest + # This package and rasuvaeff/openapi-contract are one oracle in two + # repositories: generated traffic is recorded into the contract's corpus, + # so the contract notices a change here. Nothing noticed a change THERE — + # a tightened compiler broke ten tests of this suite and stayed invisible + # until someone ran it by hand. This job is that run, and it may not gate + # the build: the contract's master is allowed to be ahead of what this + # package supports, and a red result is a heads-up, not a verdict. + continue-on-error: true + + 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 with the contract's development branch + run: | + composer config minimum-stability dev + composer config prefer-stable true + composer require rasuvaeff/openapi-contract:dev-master --no-update --no-interaction + composer update --no-interaction --no-progress + + - name: Test + run: composer test + coverage: name: Coverage & Mutation needs: changes diff --git a/AGENTS.md b/AGENTS.md index 38f4bdc..28694ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,31 @@ every mutation of it is masked. `RequestReproducer::redactCase()` had four such guards over `$body['encoding']` and became one `match`; the escapes went with them. Masked mutants are a shape, not a fact of life. +## The contract package is the other half of the oracle + +`rasuvaeff/openapi-contract` depends on nothing here, and this package depends +on it — but the two are one oracle: generated traffic is recorded into the +contract's corpus, so a change *here* shows up *there*. The reverse does not +hold, and it has already cost a day: the contract tightened its compiler and +ten tests of this suite went red without anything noticing until someone ran +them by hand. + +Two habits close it: + +- the `Contract dev-master` job in `build.yml` runs this suite against the + contract's development branch on every relevant PR. It is `continue-on-error` + on purpose — the contract's master may legitimately be ahead of what this + package supports — so read it, do not ignore it; +- before releasing either package, run the suite of the other. From the + monorepo root: + `docker run --rm -v "$PWD/property-testing-openapi":/app -v "$PWD":/repo -v "$PWD/docs/reviews/probe-openapi-contract-2026-09-06":/s -w /app composer:2 sh /s/pto-against-master.sh` + +A document this package builds only to prove a refusal must not go through +`Contract::fromArray()` when the contract already refuses it: hand-build the +`Operation`. `forOperation()` and `partContentTypeMismatchForOperation()` take +one by signature, and that is the only path such a shape can still travel in +production. + ## When you finish Run `composer build`, `composer rector`, and `git diff --check`. Run mutation diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3765e..9a672f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## Unreleased + +- **Internal.** Two tests built documents `rasuvaeff/openapi-contract` no + longer accepts, so this suite went red against its development branch while + staying green against the release. The shared multipart fixture is a document + that compiles again, and the malformed header declarations it carried — a + value that is not an object, a name that is not a string — moved to a test + that hands the generator an `Operation` built by hand, which is the only way + such a shape can still reach it. `rejectsAPartContentTypeMismatchItCannotConstruct` + builds its operation the same way, so every dataset reaches the refusal it + is named for. + +- **Internal.** A `Contract dev-master` job runs this suite against the + contract's development branch on every relevant pull request. It does not + gate the build — that master may legitimately be ahead of what this package + supports — but the day it goes red is the day to look, instead of finding out + at release time. + ## 0.10.0 — 2026-09-05 - **Added.** `NegativeRequestCaseArbitrary::partContentTypeMismatchForOperation()` diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index 0a7e132..e6f6bc1 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -801,6 +801,46 @@ public function avoidsASubstituteTheDeclarationAllows(): void Assert::false($contract->validateRequest($request)->isValid()); } + /** + * A part header declaration this generator cannot read — a value that is + * not an object, a name that is not a string — is skipped rather than + * generated from. + * + * The shape used to live in the shared multipart fixture. It cannot: since + * openapi-contract 0.8 such a document does not compile at all, and the + * only way the shape still reaches a generator is an `Operation` built by + * hand, which `forOperation()` accepts by signature. + */ + public function skipsPartHeaderDeclarationsItCannotRead(): void + { + $operation = new Operation( + key: 'upload.create', + operationId: 'upload.create', + method: 'POST', + path: '/upload', + requestBody: ['required' => true, 'content' => ['multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['title'], + 'additionalProperties' => false, + 'properties' => ['title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 12]], + ], + 'encoding' => ['title' => ['contentType' => 'text/plain', 'headers' => [ + 'X-Malformed' => 'ignored', + 7 => ['required' => true, 'example' => 'seven'], + 'X-Kept' => ['required' => true, 'example' => 'yes'], + ]]], + ]]], + responses: ['201' => []], + ); + + $case = (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(11))->value; + $parts = $case['body']['parts'] ?? []; + + Assert::same(count($parts), 1); + Assert::same($parts[0]['headers'], ['X-Kept' => 'yes']); + } + /** * Every document the category cannot be built from, each named for its * reason. The misuse rewrites a part a valid case already carries, under a @@ -816,7 +856,7 @@ public function rejectsAPartContentTypeMismatchItCannotConstruct(array $requestB Expect::exception(UnsupportedGeneration::class)->withMessage('Operation "uploads.create" has no required multipart body declaring a part content type'); (new NegativeRequestCaseArbitrary())->partContentTypeMismatchForOperation( - $this->partContract($requestBody)->operation('uploads.create'), + $this->partOperation($requestBody), ); } @@ -886,6 +926,27 @@ private function encodedPartContract(string $contentType = 'text/plain'): Contra } /** @param array $requestBody */ + /** + * Built by hand rather than compiled: several of the bodies above are + * shapes `Contract::fromArray()` refuses outright since openapi-contract + * 0.8, and the refusal under test is this package's. A hand-built + * `Operation` is also the only way such a shape can still reach the + * generator at all, so it is the path worth pinning. + * + * @param array $requestBody + */ + private function partOperation(array $requestBody): Operation + { + return new Operation( + key: 'uploads.create', + operationId: 'uploads.create', + method: 'POST', + path: '/uploads', + requestBody: $requestBody, + responses: ['204' => []], + ); + } + private function partContract(array $requestBody): Contract { return Contract::fromArray(['openapi' => '3.1.0', 'paths' => ['/uploads' => ['post' => [ diff --git a/tests/Support/BodyContracts.php b/tests/Support/BodyContracts.php index b2f32d7..fd95ddc 100644 --- a/tests/Support/BodyContracts.php +++ b/tests/Support/BodyContracts.php @@ -31,9 +31,12 @@ public static function multipart(): Contract 'flag' => ['type' => 'boolean'], ]], 'encoding' => [ + // Only declarations a document may legally carry: the + // shapes `Contract::fromArray()` refuses live in + // RequestCaseArbitraryTest, on an Operation built by + // hand, which is where they can still reach the + // generator. 'title' => ['contentType' => 'text/markdown', 'headers' => [ - 'X-Malformed' => 'ignored', - 7 => ['required' => true, 'example' => 'seven'], 'X-Unspecified' => ['example' => 'u'], 'X-Optional' => ['required' => false, 'example' => 'no'], 'X-Example' => ['required' => true, 'example' => 'yes'],