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
73 changes: 61 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,29 @@ into the monorepo) plus `git config --global --add safe.directory "*"`.

## Invariants & gotchas

- Keep `RequestCaseData` JSON-compatible. It must never contain PSR-7 objects,
credentials, closures, or application DTOs.
- Keep `CaseData` JSON-compatible. It must never contain PSR-7 objects,
credentials, closures, or application DTOs. The shape is declared once on
`ContractSuite` (`CaseData` and its parts) and imported everywhere else;
`Internal\CaseShape::assert()` is the run-time check every `@api` entry
point that takes a case runs first, and `InvalidCase` — not
`UnsupportedGeneration` — is what a malformed *case* raises. Keep the two
apart: `UnsupportedGeneration` is a document limitation.
- A materialized valid case must pass `Contract::validateRequest()` before a
transport may observe it.
- A header is written verbatim, a path and a query are percent-encoded, and a
cookie is percent-encoded. That is not a style question but a wire question:
the validator reads a header field value as sent (openapi-contract#66), so
encoding one here would put a string on the wire that no client sends.
`ParameterSchemas::separatorOf()` narrows the alphabet accordingly and
`isHeaderSafe()` guards what a `pattern` or a `format` can still put outside
a field value — the same two halves as the path rule below. A CR or an LF
reaching a materializer is refused by name, never encoded away.
`ParameterSchemas::separatorOf()` narrows the alphabet of generated plain
strings accordingly and `isHeaderSafe()` is the judgement for everything
the alphabet cannot see — a `pattern`, a `format`, an enum member, a const:
obs-text and an interior space are read as sent and kept, whitespace at an
end is stripped by the reader and refused, a comma is refused only in a
list/object header (#123, #129). `forLocation()` narrows a header enum by
that judgement and refuses at compile time when nothing remains; a path or
header `pattern` is probed at `forOperation()` time for the same reason —
the same two halves as the path rule below. A CR or an LF reaching a
materializer is refused by name, never encoded away.
- Keep parameter serialization location-aware. A path value must not escape its
template segment after percent decoding: `Internal\ParameterSchemas` raises
`minLength` to 1 on every path string, drops unsafe `enum` members, refuses
Expand All @@ -70,10 +81,23 @@ into the monorepo) plus `git config --global --add safe.directory "*"`.
`null` enum members and a `null` const at every nesting level.
- Every unsatisfiable combination the compiler can recognise fails closed at
compile time (`pattern` + asserted `format`, format length bands,
`uniqueItems` over a finite domain, `not.type` covering the source, `allOf`
branch bounding `additionalProperties` without its siblings' properties).
Do not push such checks into `Gen::filter()`; a run-time
`GenerationExhausted` is a defect here.
`uniqueItems` over a finite domain including a bounded integer, `not.type`
covering the source, `allOf` branch bounding `additionalProperties` without
its siblings' properties, an exploded form object whose `minProperties` its
declared properties cannot meet, `oneOf` over `integer`/`number` with no
value to keep apart). Object cardinality is met by construction in
`ContainerArbitraries::objectValues()`, never by a count filter. Do not push
such checks into `Gen::filter()`; a run-time `GenerationExhausted` is a
defect here. Where a filter is unavoidable (a `pattern` on the path or
header wire), probe it at compile time with the same retry budget and refuse
by name.
- A generated float goes on the wire through `WireValue` as `json_encode`
spells it, never `(string)` (precision=14 rounds); a decimal `multipleOf`
product is kept as the clean decimal only where the validator's float-mode
arithmetic agrees, else as the product itself
(`ScalarArbitraries::multipleOf()`, #117). Under `ext-bcmath` the contract's
verdict is its own (openapi-contract#151); tests that pin multipleOf
agreement skip there.
- 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 Expand Up @@ -127,8 +151,9 @@ into the monorepo) plus `git config --global --add safe.directory "*"`.

## Mutation gate: known equivalent classes

`composer mutation` (minMsi 92, against a measured 93.05% — see the comment
in `infection.json5` for why the gate is set below the score and not at it)
`composer mutation` (minMsi 91, against a measured 91.96% on 4040 mutants
after the 0.15.0 wave — see the comment in `infection.json5` for why the
gate is set below the score and not at it)
leaves a stable set of escaped mutants that
are equivalent by analysis — do not chase them, and re-classify anything new:
`Gen::frequency` weight bumps that scale every pair uniformly, values in
Expand Down Expand Up @@ -280,6 +305,30 @@ message — so the provider carries the both-bounds-at-the-limit cases that make
the overflow observable as a `TypeError` instead.


The 1.0-readiness wave (2026-09-19, 0.15.0) adds: the probe loops of
`RequestCaseArbitrary::yieldsSomething()` and
`CompositionArbitraries::yieldsSomething()` (budget and bound variants answer
the same question, as `fitsLengthWindow()`'s do); the `floor`/`ceil`/`round`
choice in the non-integral test of the numeric `oneOf` (`f(v) !== v` detects
a fractional part whichever `f` is) and in `numberBranchAdmits()` (the product
equals the value only when the quotient is exact, whichever way it is
rounded), together with its `<` → `<=` on the `1e-14` tolerance and the
`(float)` casts on operands a float already touches; `++$numeric` → `--`,
since the count is only compared with zero; the `(float)` cast and the `<=`
in `ScalarArbitraries::multipleOf()` for the same reasons; the `&&` → `||`
between `array_key_exists('const')` and the header-safety of the const (an
absent key reads as `null`, which is safe, and the mutant differs only by a
PHP warning); the `CaseShape::assert()` calls in `checkValid()` and
`reproduce()`, which the materializer repeats on the same case and so throw
the same `InvalidCase` a step later (the one in `redact()` is not repeated and
stays killed); the four `||`/`&&` rewrites inside `CaseShape`'s misuse guard,
which agree on every non-array and every array missing a member; the
`declaredFloor` ternary in `ContainerArbitraries::object()`, whose two arms
coincide once the earlier `minProperties`-versus-declared refusal has run;
the `present ?? true` default on an optional that always carries `present`;
and the `(path || header) && pattern` guard on the compile-time probe, whose
widening only probes arbitraries no filter can exhaust.

## The contract package is the other half of the oracle

`rasuvaeff/openapi-contract` depends on nothing here, and this package depends
Expand Down
101 changes: 101 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,107 @@ 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.0 — 2026-09-19

The 1.0-readiness review of 2026-09-18 (#117–#129). A minor on 0.x: the
constructor of `ResponseMaterializer` narrows, the `OperationPropertyFailed`
factories take one more argument, the `RequestCaseData` /
`NegativeRequestCaseData` psalm aliases are gone, and case-shape errors are a
type of their own.

- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.12` and
`rasuvaeff/property-testing-core` `^0.10 || ^0.11` (develops against
`rasuvaeff/property-testing-testo` `^0.10 || ^0.11`): the compile-time
probes catch `GenerationExhaustedException` by name, which exists since the
core's 0.10 contract freeze, so `^0.5`–`^0.9` are no longer accepted. The
directional schema
rewrite is delegated to `SchemaCheck::effective()` — this package's own
copy never recursed into `additionalProperties` — and the request body and
responses arrive as the contract's typed shapes, so the guards that
re-checked them are gone.
- **Fixed.** A numeric parameter reached the wire through `(string)`, which
rounds to `precision=14` and put a different number there for any value
that needed more digits (`846608010056.187` went as `846608010056.19`). A
float is spelled the way `json_encode` spells it now. The `multipleOf`
generator also rounded `k × m` back to the decimal it meant, which from
about `64` upwards is one ulp away from the product the validator
computes and tolerates, so a third of the draws for `0.1` were rejected;
the decimal spelling is kept only where the validator agrees with it.
Under `ext-bcmath` the contract evaluates `multipleOf` in decimal
arithmetic and its verdict is its own — openapi-contract#151 (#117).
- **Fixed.** `missing-required` no longer targets a path parameter, and
`negativeCoverage()` no longer lists it: omitting one leaves the template
literal in the request target, which a `string` schema accepts, so the
negative phase failed on any `/{slug}` operation with
`unexpectedlyValidRequest`. An operation whose only required component is a
path parameter skips the category and names the reason (#118).
- **Fixed.** `+` stays `%2B` under `allowReserved`: a raw plus in a query is
a space to the validator and to every SAPI (#119).
- **Fixed.** A form body property with an object schema and `explode: true`
is generated without undeclared members — its flat `member=value` pairs can
carry only the declared ones, and an extra landed as a top-level member of
the body. A nested object whose `minProperties` its declared properties
cannot meet fails closed at compile time (#120).
- **Fixed.** `oneOf` over an `integer` and a `number` branch was compiled as a
plain choice between disjoint branches, and half the draws matched both.
The number branch is generated without integral values, the integer branch
keeps only what the number branch's bounds and multiple refuse, and a
number branch that admits every value outside the integer branch fails
closed. `1.0` is an integer to JSON Schema, and is treated as one (#121).
- **Fixed.** A multipart part declared under a JSON media type carries the
JSON encoding of its value; `text/*` and `application/octet-stream` parts
carry it verbatim; any other part media type fails closed instead of
sending a body the generator cannot vouch for (#122).
- **Fixed.** Three legal documents reached a run-time `GenerationExhausted`:
a header enum outside ASCII (`isHeaderSafe()` admits obs-text now, as RFC
9110 and the validator do), a path pattern that always carries a slash
(probed at compile time and refused by name, as is a header pattern none
of whose strings survives the wire), and an object whose twelve optionals
were drawn as independent presence choices against `maxProperties: 1`
(`minProperties`/`maxProperties` are met by construction: an optional past
the ceiling is left out, one needed for the floor brought in). `uniqueItems`
with `minItems` over a bounded integer domain smaller than it fails closed
at compile time (#123).
- **Fixed.** A header enum member with an interior space (`New York`) was
refused at compile time although the validator accepts it: a scalar
header's members are refused only for whitespace at an end or a control
character, a list or object header's additionally for a comma (#129).
- **Added.** `ContractSuite::redaction(RedactionPolicy)` configures the policy
every rendering goes through: `reproduce()` by default (its policy
argument is optional now) and the minimal case `OperationPropertyFailed`
prints, which used to be the unredacted counterexample JSON.
`ContractSuite::redact(array $case): array` applies it to a case. The
READMEs and `llms.txt` claimed `Cookie` was in the default redacted header
set; it is not, by design since #73, and they say so now (#124).
- **Added.** `OpenApiPropertyTestingException`, an empty marker interface
implemented by every exception of the package; `InvalidCase`
(`\InvalidArgumentException`) for a case that does not have the exported
shape — the materializer and the serializer threw `UnsupportedGeneration`
for it, which is a document limitation, not a caller error; a
`Psr15Transport` configuration error is a `SuiteConfigurationError` rather
than a bare `\LogicException`. `UnsupportedGeneration::forSchema()`
refusals name the operation and the parameter or body being compiled
(`Unsupported OpenAPI schema generation for operation "pets.list", query
parameter "limit": minLength exceeds maxLength`) through
`inOperation()` (#127).
- **Changed.** The case shape is declared once, on `ContractSuite`
(`CaseData` with `ParameterMap`, `BodyData`, `PartData`, `MisuseData`), and
imported everywhere else; the `RequestCaseData` and
`NegativeRequestCaseData` aliases and the inline copies are gone.
`checkValid()`, `checkNegative()`, `reproduce()`, `redact()` and
`RequestMaterializer::materialize()` refuse a case missing a key with
`InvalidCase` naming it — a hand-written case without `misuse` used to
raise a PHP warning and pass `checkValid()` (#128).
- **Changed.** `ResponseMaterializer::__construct()` no longer takes the
`@internal` `JsonBodyEncoder` and `ParameterSerializer` as defaulted
parameters; it builds them, as `RequestMaterializer` does (#125).
`CheckFailed::$result` is `readonly`, assigned through the constructor by
the factories (#126). `OperationPropertyFailed::forCounterExample()` and
`forExample()` take the redacted case to print as their last argument.
- Zoo: `labels.get`, `sparse.create`, `amounts.create`, `settings.create`,
`precise.get`; the contract's generated corpus is re-recorded from it
(openapi-contract#152).

## 0.14.1 — 2026-09-18

- **Fixed.** Valid number schemas now keep exclusive bounds at the adjacent
Expand Down
Loading
Loading