From 477dd4d4c4f85143f55e9dfa38d2eca0a0cd6b71 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Fri, 2 Oct 2026 22:05:44 +0300 Subject: [PATCH 1/4] Add #[Generate] and the refined string and array shape types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #[Generate] puts a parameter's generator on the parameter itself: an arbitrary built with `new`, or a reference to a static factory returning one. Gen::forParameters() and Gen::forClass() read it after an override and before the docblock, so both adapters' signature-driven modes get it from one change. Gen::forParameters(derive: false) reads only overrides and the attribute — the engine half of an adapter's mode without auto. The docblock reader now understands numeric-string, non-falsy-string, lowercase-string, non-empty-lowercase-string and array shapes with optional, quoted, integer and positional keys, and reads a @param written across lines. Fixes #156 Fixes #160 --- CHANGELOG.md | 22 ++ README.md | 45 ++- README.ru.md | 44 ++- docs/scripts/api-snapshot.json | 270 +++++++++++++++--- docs/src/api/classes/Gen.md | 7 +- docs/src/api/classes/Generate.md | 46 +++ docs/src/api/classes/Runner/PropertyRunner.md | 2 +- docs/src/api/index.md | 1 + llms.txt | 30 +- .../rasuvaeff-property-testing-core/SKILL.md | 3 +- src/Gen.php | 12 +- src/Generate.php | 41 +++ src/Internal/DocblockTypes.php | 13 +- src/Internal/ParameterGenerators.php | 123 +++++++- src/Internal/TypeGenerators.php | 160 ++++++++++- src/Runner/Redis/RedisDsn.php | 2 +- tests/Arbitrary/ClassArbitraryTest.php | 2 +- tests/GenerateAttributeTest.php | 203 +++++++++++++ tests/Internal/RefinedTypesTest.php | 269 +++++++++++++++++ tests/Support/Fixtures/GenerateFixtures.php | 128 +++++++++ .../Support/Fixtures/RefinedTypeFixtures.php | 65 +++++ 21 files changed, 1415 insertions(+), 73 deletions(-) create mode 100644 docs/src/api/classes/Generate.md create mode 100644 src/Generate.php create mode 100644 tests/GenerateAttributeTest.php create mode 100644 tests/Internal/RefinedTypesTest.php create mode 100644 tests/Support/Fixtures/GenerateFixtures.php create mode 100644 tests/Support/Fixtures/RefinedTypeFixtures.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5f6a2..c5d3f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +- **Added:** `#[Generate]` — a parameter attribute holding its generator, read by + `Gen::forParameters()` and `Gen::forClass()` after an override and before the + docblock and the native type (#160). It takes an arbitrary built with `new`, or + a reference to a static factory returning one (`'method'`, `'Class::method'`, + `[Class::class, 'method']`, an invokable) — the reference is how a generator + built with a closure (`map`, `email`, `regex`) reaches a parameter on PHP 8.3 + and 8.4. A broken attribute is an exception naming the parameter. +- **Added:** `Gen::forParameters(derive: false)` reads only overrides and + `#[Generate]` and refuses a parameter with neither — the engine half of an + adapter's mode without `auto`. +- **Added:** the docblock reader understands `numeric-string`, + `non-falsy-string`/`truthy-string`, `lowercase-string`, + `non-empty-lowercase-string` and array shapes — `array{a: T, b?: U}`, + quoted and integer keys, positional `array{T, U}` and `list{T, U}`, `array{}`, + shapes nested in collections and unions (#156). An optional key is sometimes + absent and shrinks towards absent. Unsealed shapes and double-quoted keys stay + refused. A `@param` type written across several lines is now read. +- **Changed:** refusals about a parameter now end with + `pass an override or #[Generate]`. + ## 1.1.0 — 2026-09-28 - **Changed:** a shrink candidate is accepted only when it fails the same way diff --git a/README.md b/README.md index 62f9203..3404c0a 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,49 @@ through their source domain. | `Gen::commands($initialModel, $commandGenerators, $minLength, $maxLength)` | `CommandSequenceArbitrary`, valid command sequences for stateful testing | drops command blocks, then simplifies each command | | `Gen::rules($machine, $minLength, $maxLength)` | `RuleSequence` over a rule-based machine class (`#[Rule]`/`#[Precondition]`/`#[Invariant]` methods) — see [Rule-based machines](#rule-based-machines-genrules) | like `commands`: drops steps, then simplifies each step's arguments | | `Gen::swarm($arbitrary)` | `SwarmArbitrary`, swarm testing: each case may use only a non-empty subset of the wrapped choice generator's variants (`oneOf`, `elements`, `frequency`, `commands`) | inside the subset the case came from — never widening back to the full alphabet | -| `Gen::forClass($class, $overrides)` | `ClassArbitrary`, instances built from what the constructor declares — the docblock psalm type when there is one (`int<0, 100>`, `non-empty-string`, `list`, `Status\|null`, `'a'\|'b'`; class names resolve through the file's namespace and `use` imports), the native type otherwise. Three spellings are read: `@psalm-param`/`@phpstan-param` win over `@param`, and a `@var` (`@psalm-var`) on the promoted property itself is read when the constructor docblock says nothing about it. A native `float` means `floatBetween(-1e6, 1e6)`. Anything unreadable — a bare `array`, `mixed`, a native union, a docblock type outside the subset, an unknown class name (named in the message) — throws instead of guessing, an override naming no parameter too | through the generated arguments, rebuilding the instance | -| `Gen::forParameters($function, $overrides)` | not an arbitrary but a map: `array` for the parameters of a `ReflectionFunctionAbstract` (method or closure), by name in signature order — the `forClass` rules applied to any signature; overrides may be partial, the rest is derived; anything unreadable throws naming the function and the parameter | each entry shrinks through its own generator | +| `Gen::forClass($class, $overrides)` | `ClassArbitrary`, instances built from what the constructor declares — a [`#[Generate]`](#generators-on-the-parameter-generate) attribute on the parameter, then the docblock psalm type when there is one (`int<0, 100>`, `non-empty-string`, `numeric-string`, `lowercase-string`, `non-falsy-string`, `list`, `array{id: int, name?: string}`, `Status\|null`, `'a'\|'b'`; class names resolve through the file's namespace and `use` imports), the native type otherwise. Three spellings are read: `@psalm-param`/`@phpstan-param` win over `@param`, and a `@var` (`@psalm-var`) on the promoted property itself is read when the constructor docblock says nothing about it. A native `float` means `floatBetween(-1e6, 1e6)`. Anything unreadable — a bare `array`, `mixed`, a native union, a docblock type outside the subset, an unknown class name (named in the message) — throws instead of guessing, an override naming no parameter too | through the generated arguments, rebuilding the instance | +| `Gen::forParameters($function, $overrides, derive: true)` | not an arbitrary but a map: `array` for the parameters of a `ReflectionFunctionAbstract` (method or closure), by name in signature order — the `forClass` rules applied to any signature; overrides may be partial, the rest is derived; anything unreadable throws naming the function and the parameter. `derive: false` reads only overrides and `#[Generate]` and refuses a parameter with neither | each entry shrinks through its own generator | + +### Generators on the parameter: `#[Generate]` + +A parameter can carry its generator itself. `Gen::forParameters()` and +`Gen::forClass()` read it after an override and before the docblock and the +native type, so it reaches every adapter's signature-driven mode: + +```php +use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary; +use Rasuvaeff\PropertyTesting\Generate; + +public function delayStaysWithinCap( + #[Generate(new IntArbitrary(0, 10_000))] + int $base, + #[Generate([Generators::class, 'email'])] + string $to, +): void {} +``` + +The argument is either an arbitrary built with `new` or a reference to a static +factory that returns one: a method name of the declaring class, +`'Class::method'`, `[Class::class, 'method']`, or an invokable object. An +attribute argument is a constant expression, so `Gen::*` calls cannot appear in +it, and on PHP 8.3/8.4 neither can a closure: + +| Expressible with `new` | Needs a factory reference | +|---|---| +| `IntArbitrary`, `FloatArbitrary`, `BoolArbitrary`, `StringArbitrary`, `CharsetStringArbitrary`, `BytesArbitrary`, `UuidArbitrary`, `DateTimeArbitrary` | `Gen::map()`, `filter()`, `flatMap()`, `composite()` — they take a closure | +| `ArrayArbitrary`, `DictionaryArbitrary`, `TupleArbitrary`, `RecordArbitrary`, `SubsetArbitrary`, `NullableArbitrary`, nested: `new ArrayArbitrary(new IntArbitrary(0, 9), 1, 10)` | `Gen::email()`, `url()`, `json()`, `jsonString()`, `intRange()`, `randomizer()` — built on `map()` | +| `OneOfArbitrary` (`Gen::elements()`, `oneOf()`, `enum()` — `new OneOfArbitrary(Status::A, Status::B)`), `ConstantArbitrary`, `FrequencyArbitrary` | `Gen::regex()`, `stringMatching()`, `recursive()` | +| `ClassArbitrary` (`Gen::forClass()`) | dependent parameters — one attribute sees one parameter | + +The attribute is exact: it wins over a docblock it disagrees with (and stands in +for one this cannot read), and a nullable parameter is not made nullable on top +of it. On a variadic it counts as the override. Everything that can be wrong +with it — constructor arguments the arbitrary rejects, a reference that is not +a static factory, a factory returning something else — is an exception naming +the parameter. + +`Generate` belongs to test code. `Gen::forClass()` reads it on any constructor, +but a production class carrying it would depend on this package at runtime. Numeric generators (`int*`, `float*`) are **boundary-biased**: roughly one draw in five returns an in-range edge value (`0`, `±1`, `min`, `max` for ints; `0.0` or diff --git a/README.ru.md b/README.ru.md index 7ec2aec..880bc75 100644 --- a/README.ru.md +++ b/README.ru.md @@ -258,8 +258,48 @@ harness может написать `catch (PropertyTestingException $e)` для | `Gen::regex($pattern)` / `Gen::stringMatching($pattern)` | строки, соответствующие подмножеству regex (компилируется в комбинаторы), паттерн — **без разделителей**: `Gen::regex('[a-z]{3,6}')`, а не `'/[a-z]{3,6}/'`; `.` и отрицающий класс берут символы из printable ASCII (`0x20`..`0x7E`, без перевода строки) | более короткие/простые совпадения (через скомпилированные деревья) | | `Gen::commands($initialModel, $commandGenerators, $minLength, $maxLength)` | `CommandSequenceArbitrary`, валидные последовательности команд для stateful-тестирования | сбрасывает блоки команд, затем упрощает каждую | | `Gen::rules($machine, $minLength, $maxLength)` | `RuleSequence` над классом rule-машины (методы `#[Rule]`/`#[Precondition]`/`#[Invariant]`) — см. [Rule-машины](#rule-машины-genrules) | как `commands`: сбрасывает шаги, затем упрощает аргументы каждого || `Gen::swarm($arbitrary)` | `SwarmArbitrary`, swarm-тестирование: каждый случай видит лишь непустое подмножество вариантов обёрнутого генератора выбора (`oneOf`, `elements`, `frequency`, `commands`) | внутри подмножества, из которого случай получился, — обратно до полного алфавита не расширяется | -| `Gen::forClass($class, $overrides)` | `ClassArbitrary`, экземпляры по тому, что объявляет конструктор: psalm-тип из докблока, если он есть (`int<0, 100>`, `non-empty-string`, `list`, `Status\|null`, `'a'\|'b'`; имена классов резолвятся через namespace и `use`-импорты файла), иначе нативный. Читаются три написания: `@psalm-param`/`@phpstan-param` берут верх над `@param`, а `@var` (`@psalm-var`) на самом promoted-свойстве читается, когда докблок конструктора о нём молчит. Нативный `float` означает `floatBetween(-1e6, 1e6)`. Всё, что прочитать нельзя — голый `array`, `mixed`, нативный union, докблок-тип вне подмножества, неизвестное имя класса (называется в сообщении), — исключение, а не догадка, как и override с именем несуществующего параметра | через сгенерированные аргументы, пересобирая экземпляр | -| `Gen::forParameters($function, $overrides)` | не arbitrary, а карта: `array` для параметров `ReflectionFunctionAbstract` (метода или кложуры), по именам в порядке сигнатуры — правила `forClass`, применённые к любой сигнатуре; overrides могут быть частичными, остальное достраивается; всё нечитаемое — исключение с именем функции и параметра | каждая запись shrink'ается через свой генератор | +| `Gen::forClass($class, $overrides)` | `ClassArbitrary`, экземпляры по тому, что объявляет конструктор: атрибут [`#[Generate]`](#генераторы-на-параметре-generate) на параметре, затем psalm-тип из докблока, если он есть (`int<0, 100>`, `non-empty-string`, `numeric-string`, `lowercase-string`, `non-falsy-string`, `list`, `array{id: int, name?: string}`, `Status\|null`, `'a'\|'b'`; имена классов резолвятся через namespace и `use`-импорты файла), иначе нативный. Читаются три написания: `@psalm-param`/`@phpstan-param` берут верх над `@param`, а `@var` (`@psalm-var`) на самом promoted-свойстве читается, когда докблок конструктора о нём молчит. Нативный `float` означает `floatBetween(-1e6, 1e6)`. Всё, что прочитать нельзя — голый `array`, `mixed`, нативный union, докблок-тип вне подмножества, неизвестное имя класса (называется в сообщении), — исключение, а не догадка, как и override с именем несуществующего параметра | через сгенерированные аргументы, пересобирая экземпляр | +| `Gen::forParameters($function, $overrides, derive: true)` | не arbitrary, а карта: `array` для параметров `ReflectionFunctionAbstract` (метода или кложуры), по именам в порядке сигнатуры — правила `forClass`, применённые к любой сигнатуре; overrides могут быть частичными, остальное достраивается; всё нечитаемое — исключение с именем функции и параметра. `derive: false` читает только overrides и `#[Generate]` и отказывает параметру без того и другого | каждая запись shrink'ается через свой генератор | + +### Генераторы на параметре: `#[Generate]` + +Параметр может нести генератор сам. `Gen::forParameters()` и `Gen::forClass()` +читают его после override и до докблока и нативного типа, поэтому он доходит +до режима «из сигнатуры» в каждом адаптере: + +```php +use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary; +use Rasuvaeff\PropertyTesting\Generate; + +public function delayStaysWithinCap( + #[Generate(new IntArbitrary(0, 10_000))] + int $base, + #[Generate([Generators::class, 'email'])] + string $to, +): void {} +``` + +Аргумент — либо arbitrary, построенный через `new`, либо ссылка на статическую +фабрику, которая его возвращает: имя метода объявляющего класса, +`'Class::method'`, `[Class::class, 'method']` или invokable-объект. Аргумент +атрибута — константное выражение, поэтому вызов `Gen::*` в нём невозможен, а на +PHP 8.3/8.4 — и замыкание: + +| Выражается через `new` | Нужна ссылка на фабрику | +|---|---| +| `IntArbitrary`, `FloatArbitrary`, `BoolArbitrary`, `StringArbitrary`, `CharsetStringArbitrary`, `BytesArbitrary`, `UuidArbitrary`, `DateTimeArbitrary` | `Gen::map()`, `filter()`, `flatMap()`, `composite()` — принимают замыкание | +| `ArrayArbitrary`, `DictionaryArbitrary`, `TupleArbitrary`, `RecordArbitrary`, `SubsetArbitrary`, `NullableArbitrary`, вложенно: `new ArrayArbitrary(new IntArbitrary(0, 9), 1, 10)` | `Gen::email()`, `url()`, `json()`, `jsonString()`, `intRange()`, `randomizer()` — построены на `map()` | +| `OneOfArbitrary` (`Gen::elements()`, `oneOf()`, `enum()` — `new OneOfArbitrary(Status::A, Status::B)`), `ConstantArbitrary`, `FrequencyArbitrary` | `Gen::regex()`, `stringMatching()`, `recursive()` | +| `ClassArbitrary` (`Gen::forClass()`) | зависимые параметры — атрибут видит один параметр | + +Атрибут точен: он побеждает несогласный с ним докблок (и заменяет тот, который +прочитать нельзя), а nullable-параметр не делается nullable поверх него. На +variadic он считается override. Всё, что с ним может быть не так, — аргументы, +которые arbitrary отвергает, ссылка не на статическую фабрику, фабрика, +вернувшая не то, — исключение с именем параметра. + +`Generate` — для тестового кода. `Gen::forClass()` читает его на любом +конструкторе, но production-класс с ним зависел бы от этого пакета в рантайме. Числовые генераторы (`int*`, `float*`) **boundary-biased**: примерно каждый пятый draw возвращает краевое значение диапазона (`0`, `±1`, `min`, `max` для diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index fbce89f..0b36f5f 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -5326,14 +5326,14 @@ ], "returnType": "Rasuvaeff\\PropertyTesting\\ArbitraryInterface", "summary": "Instances of $class, generated from what its constructor already\ndeclares — the reflection answer to jqwik's type-driven `@ForAll` and\nRust's `derive(Arbitrary)`, in a language that has no macros but does\nhave promoted constructor properties and psalm annotations.", - "description": "```php\nGen::forClass(Money::class);\nGen::forClass(Money::class, ['amount' => Gen::intPositive()]);\n```\n\nPer parameter, in order: an override, then the docblock (psalm subset;\n`@psalm-param`/`@phpstan-param` over `@param`, and the promoted\nproperty's own `@var` when the constructor docblock is silent), then\nthe native type. The docblock wins over the native type because it says\nmore — `int` and `int<0, 100>` are the same native type and a very\ndifferent value space — and a type this cannot read is an exception\nnaming the parameter rather than a widened guess. See\n\\Rasuvaeff\\PropertyTesting\\Arbitrary\\ClassArbitrary for the supported subset and for what a validating\nconstructor does.", + "description": "```php\nGen::forClass(Money::class);\nGen::forClass(Money::class, ['amount' => Gen::intPositive()]);\n```\n\nPer parameter, in order: an override, then a \\Rasuvaeff\\PropertyTesting\\Generate attribute\non the parameter, then the docblock (psalm subset;\n`@psalm-param`/`@phpstan-param` over `@param`, and the promoted\nproperty's own `@var` when the constructor docblock is silent), then\nthe native type. The docblock wins over the native type because it says\nmore — `int` and `int<0, 100>` are the same native type and a very\ndifferent value space — and a type this cannot read is an exception\nnaming the parameter rather than a widened guess. See\n\\Rasuvaeff\\PropertyTesting\\Arbitrary\\ClassArbitrary for the supported subset and for what a validating\nconstructor does.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 397 + "startLine": 398 }, { "name": "forParameters", @@ -5349,7 +5349,7 @@ { "name": "overrides", "type": "array", - "description": "Generators by parameter name, winning over\nanything the parameter declares.", + "description": "Generators by parameter name, winning over\n anything the parameter declares.\nWith `$derive: false` nothing is read from the declared types: every\nparameter needs an override or a \\Rasuvaeff\\PropertyTesting\\Generate attribute, and one\nwithout either is refused. That is an adapter's mode without `auto` —\ngenerators stated explicitly, some in a provider, some on the parameters.", "default": "[]", "variadic": false }, @@ -5359,6 +5359,13 @@ "description": "How deep to follow class-typed parameters before refusing.", "default": "3", "variadic": false + }, + { + "name": "derive", + "type": "bool", + "description": "Whether a parameter with neither an override nor\na \\Rasuvaeff\\PropertyTesting\\Generate attribute is derived from its declared type.", + "default": "true", + "variadic": false } ], "returnType": "array", @@ -5370,7 +5377,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 437 + "startLine": 445 }, { "name": "swarm", @@ -5393,7 +5400,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 477 + "startLine": 487 }, { "name": "elements", @@ -5421,7 +5428,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 496 + "startLine": 506 }, { "name": "constant", @@ -5444,7 +5451,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 514 + "startLine": 524 }, { "name": "enum", @@ -5467,7 +5474,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 529 + "startLine": 539 }, { "name": "floatSpecial", @@ -5482,7 +5489,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 555 + "startLine": 565 }, { "name": "intRange", @@ -5512,7 +5519,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 577 + "startLine": 587 }, { "name": "recursive", @@ -5549,7 +5556,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 614 + "startLine": 624 }, { "name": "nullable", @@ -5572,7 +5579,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 644 + "startLine": 654 }, { "name": "map", @@ -5602,7 +5609,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 662 + "startLine": 672 }, { "name": "flatMap", @@ -5632,7 +5639,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 682 + "startLine": 692 }, { "name": "filter", @@ -5662,7 +5669,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 697 + "startLine": 707 }, { "name": "draw", @@ -5685,7 +5692,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 734 + "startLine": 744 }, { "name": "composite", @@ -5708,7 +5715,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 772 + "startLine": 782 }, { "name": "note", @@ -5738,7 +5745,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 798 + "startLine": 808 }, { "name": "tuple", @@ -5761,7 +5768,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 810 + "startLine": 820 }, { "name": "frequency", @@ -5784,7 +5791,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 826 + "startLine": 836 }, { "name": "uuid", @@ -5799,7 +5806,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 839 + "startLine": 849 }, { "name": "randomEngine", @@ -5814,7 +5821,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 872 + "startLine": 882 }, { "name": "randomizer", @@ -5829,7 +5836,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 883 + "startLine": 893 }, { "name": "datetime", @@ -5859,7 +5866,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 895 + "startLine": 905 }, { "name": "ipv4", @@ -5874,7 +5881,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 906 + "startLine": 916 }, { "name": "ipv6", @@ -5889,7 +5896,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 935 + "startLine": 945 }, { "name": "email", @@ -5904,7 +5911,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 959 + "startLine": 969 }, { "name": "url", @@ -5919,7 +5926,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 983 + "startLine": 993 }, { "name": "json", @@ -5942,7 +5949,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1011 + "startLine": 1021 }, { "name": "jsonString", @@ -5965,7 +5972,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1037 + "startLine": 1047 }, { "name": "regex", @@ -5995,7 +6002,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1073 + "startLine": 1083 }, { "name": "stringMatching", @@ -6025,7 +6032,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1086 + "startLine": 1096 }, { "name": "commands", @@ -6069,7 +6076,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1108 + "startLine": 1118 }, { "name": "rules", @@ -6106,7 +6113,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1176 + "startLine": 1186 }, { "name": "sample", @@ -6143,7 +6150,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1200 + "startLine": 1210 }, { "name": "sampleShrinks", @@ -6180,7 +6187,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1229 + "startLine": 1239 } ], "constants": [], @@ -6188,6 +6195,43 @@ "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Gen.php#L58", "implementedBy": [] }, + { + "root": "core", + "rootVersion": "working tree", + "rootReference": null, + "class": "Rasuvaeff\\PropertyTesting\\Generate", + "kind": "class", + "isApi": true, + "isAbstract": false, + "isThrowable": false, + "summary": "The generator of one parameter, written on the parameter itself — read by\nGen::forParameters() and Gen::forClass(), so by every adapter's\nsignature-driven mode, before the docblock and the native type.", + "description": "```php\npublic function delayStaysWithinCap(\n #[Generate(new IntArbitrary(0, 10_000))] int $base,\n #[Generate([Generators::class, 'email'])] string $to,\n): void\n```\n\nEither an arbitrary, built with `new` (an attribute argument is a constant\nexpression, so `Gen::*` factories cannot appear in it), or a reference to a\nstatic factory that returns one: a method name of the declaring class,\n`'Class::method'`, `[Class::class, 'method']`, or an invokable object.\nThe reference is how a generator that needs a closure — `Gen::map()`,\n`Gen::email()`, `Gen::regex()` — reaches a parameter on PHP 8.3 and 8.4.\n\nThe attribute is exact: it wins over the docblock and the native type, and\na nullable parameter is not made nullable on top of it.", + "deprecated": null, + "see": [], + "extensionTags": [], + "extends": null, + "implements": [], + "attributes": [ + "Attribute" + ], + "constructorParams": [ + { + "name": "generator", + "type": "\\Rasuvaeff\\PropertyTesting\\ArbitraryInterface|non-empty-string|array{0: class-string|object, 1: non-empty-string}|object", + "description": "The arbitrary, or a static factory returning one.", + "default": null, + "promoted": true, + "promotedVisibility": "public", + "readonly": true + } + ], + "publicProperties": [], + "publicMethods": [], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Generate.php#L32", + "implementedBy": [] + }, { "root": "core", "rootVersion": "working tree", @@ -6812,7 +6856,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 123 + "startLine": 132 } ], "constants": [], @@ -7127,6 +7171,133 @@ "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/DrawContext.php#L30", "implementedBy": [] }, + { + "root": "core", + "rootVersion": "working tree", + "rootReference": null, + "class": "Rasuvaeff\\PropertyTesting\\Internal\\FailureIdentity", + "kind": "class", + "isApi": false, + "isAbstract": false, + "isThrowable": false, + "summary": "What makes two failures of one property the same failure: the exception\nclass, and the place in the property's own file that raised it.", + "description": "The class alone is not enough. A body that can fail in more than one place —\ntwo assertions, or an assertion next to a call that throws the same type —\noffers the shrink descent several failures that look identical, so the\ndescent may leave the failure it was given and minimise a different one. The\nreported counterexample then belongs to a bug the run never found, and\nnothing in the output says so.\n\nThe place is read from the property's own file rather than from the\nthrowable's `getFile()`/`getLine()` alone, because an assertion library\nthrows from one line whatever the assertion was: PHPUnit raises every\nconstraint failure from `Constraint::fail()`, Testo every comparison from\n`Assert`. Comparing those would separate an assertion failure from a\n`TypeError` — which the class already does — and collapse two assertions\ninto one. The frame in the property's file is the assertion the body wrote.\n\nWalking to \"the first frame outside the engine\" instead does not work: under\nTesto the body is reached through the framework's interceptor pipeline, so\nthe frames next to the engine belong to Testo, not to the property.\n\nBoth sides fall back to the class alone whenever the place is unknown — an\nid that names no loaded class (an adapter-supplied string, a closure-derived\nid), a body defined in another file (a trait, an included fixture), a\nfailure raised by the engine rather than by the body. A criterion that\nguessed instead would reject candidates that fail the way the original did,\nand a descent that rejects everything stalls on the input it started from —\nthe same silence, at a larger counterexample.", + "deprecated": null, + "see": [], + "extensionTags": { + "internal": [ + "" + ] + }, + "extends": null, + "implements": [], + "attributes": [], + "constructorParams": [ + { + "name": "class", + "type": "?string", + "description": "", + "default": null, + "promoted": true, + "promotedVisibility": "private", + "readonly": true + }, + { + "name": "file", + "type": "?string", + "description": "", + "default": null, + "promoted": true, + "promotedVisibility": "private", + "readonly": true + }, + { + "name": "boundary", + "type": "string", + "description": "", + "default": null, + "promoted": true, + "promotedVisibility": "private", + "readonly": true + }, + { + "name": "site", + "type": "?int", + "description": "", + "default": null, + "promoted": true, + "promotedVisibility": "private", + "readonly": true + } + ], + "publicProperties": [], + "publicMethods": [ + { + "name": "of", + "static": true, + "params": [ + { + "name": "propertyId", + "type": "string", + "description": "The property's id; its class part, when it names a loaded\nclass, locates the file the property is written in.", + "default": null, + "variadic": false + }, + { + "name": "failure", + "type": "?\\Throwable", + "description": "What the original run failed with. Null — or a candidate\nthat fails without a throwable — leaves nothing to compare, and any failure\ncounts as the same one.", + "default": null, + "variadic": false + }, + { + "name": "boundary", + "type": "string", + "description": "The engine's own file. Frames from it outwards are the engine\ncalling the body, not the body: without that bound, an adapter that invokes the\nbody from the test file itself (PHPUnit's `check()` call) would report that call\nas the site of every failure raised outside the body, while an adapter that\ninvokes it through reflection (Testo) would report none — the same property would\nshrink differently under the two.", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\PropertyTesting\\Internal\\FailureIdentity", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 62 + }, + { + "name": "matches", + "static": false, + "params": [ + { + "name": "candidate", + "type": "?Throwable", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "Whether $candidate is the failure this identity was built from.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 77 + } + ], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/FailureIdentity.php#L40", + "implementedBy": [] + }, { "root": "core", "rootVersion": "working tree", @@ -7389,7 +7560,7 @@ "isAbstract": false, "isThrowable": false, "summary": "The parameters of one signature, as generators.", - "description": "Shared by \\Rasuvaeff\\PropertyTesting\\Arbitrary\\ClassArbitrary (a\nconstructor's parameters, instantiated) and\n\\Rasuvaeff\\PropertyTesting\\Gen::forParameters() (any function's\nparameters, handed back as a map). The resolution rules are one thing —\nan explicit override, then the docblock (\\Rasuvaeff\\PropertyTesting\\Internal\\DocblockTypes), then the native type —\nand $subject is the only difference between the callers: it names whose\nparameter a refusal is about.", + "description": "Shared by \\Rasuvaeff\\PropertyTesting\\Arbitrary\\ClassArbitrary (a\nconstructor's parameters, instantiated) and\n\\Rasuvaeff\\PropertyTesting\\Gen::forParameters() (any function's\nparameters, handed back as a map). The resolution rules are one thing —\nan explicit override, then a \\Rasuvaeff\\PropertyTesting\\Generate attribute on the parameter, then\nthe docblock (\\Rasuvaeff\\PropertyTesting\\Internal\\DocblockTypes), then the native type —\nand $subject is the only difference between the callers: it names whose\nparameter a refusal is about.", "deprecated": null, "see": [], "extensionTags": { @@ -7445,7 +7616,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 40 + "startLine": 42 }, { "name": "forSignature", @@ -7485,6 +7656,13 @@ "description": "", "default": null, "variadic": false + }, + { + "name": "derive", + "type": "bool", + "description": "Whether a parameter with neither an override nor a\n\\Rasuvaeff\\PropertyTesting\\Generate attribute is derived from its declared type, or refused.", + "default": "true", + "variadic": false } ], "returnType": "array", @@ -7496,12 +7674,12 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 78 + "startLine": 82 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/ParameterGenerators.php#L24", + "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/ParameterGenerators.php#L26", "implementedBy": [] }, { @@ -8383,7 +8561,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 47 + "startLine": 48 }, { "name": "fromNative", @@ -8413,12 +8591,12 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 115 + "startLine": 122 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/TypeGenerators.php#L28", + "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Internal/TypeGenerators.php#L29", "implementedBy": [] }, { @@ -12348,12 +12526,12 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 84 + "startLine": 85 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Runner/PropertyRunner.php#L62", + "sourceUrl": "https://github.com/rasuvaeff/property-testing-core/blob/master/src/Runner/PropertyRunner.php#L63", "implementedBy": [] }, { diff --git a/docs/src/api/classes/Gen.md b/docs/src/api/classes/Gen.md index c574f3d..bc48910 100644 --- a/docs/src/api/classes/Gen.md +++ b/docs/src/api/classes/Gen.md @@ -303,7 +303,8 @@ Gen::forClass(Money::class); Gen::forClass(Money::class, ['amount' => Gen::intPositive()]); ``` -Per parameter, in order: an override, then the docblock (psalm subset; +Per parameter, in order: an override, then a [`Generate`](/api/classes/Generate) attribute +on the parameter, then the docblock (psalm subset; `@psalm-param`/`@phpstan-param` over `@param`, and the promoted property's own `@var` when the constructor docblock is silent), then the native type. The docblock wins over the native type because it says @@ -320,6 +321,7 @@ static forParameters( \ReflectionFunctionAbstract $function, array $overrides = [], int $maxDepth = 3, + bool $derive = true, ): array ``` @@ -330,8 +332,9 @@ mode: a property method whose parameters are fully typed needs no provider at all. - `$function` — The function, method or closure whose parameters to read. -- `$overrides` — Generators by parameter name, winning over anything the parameter declares. +- `$overrides` — Generators by parameter name, winning over anything the parameter declares. With `$derive: false` nothing is read from the declared types: every parameter needs an override or a [`Generate`](/api/classes/Generate) attribute, and one without either is refused. That is an adapter's mode without `auto` — generators stated explicitly, some in a provider, some on the parameters. - `$maxDepth` — How deep to follow class-typed parameters before refusing. +- `$derive` — Whether a parameter with neither an override nor a [`Generate`](/api/classes/Generate) attribute is derived from its declared type. ```php Gen::forParameters(new \ReflectionMethod(BackoffTest::class, 'delayStaysWithinCap')); diff --git a/docs/src/api/classes/Generate.md b/docs/src/api/classes/Generate.md new file mode 100644 index 0000000..ea36309 --- /dev/null +++ b/docs/src/api/classes/Generate.md @@ -0,0 +1,46 @@ +--- +title: "Generate" +description: "The generator of one parameter, written on the parameter itself — read by Gen::forParameters() and Gen::forClass(), so by every adapter's…" +--- + + + +# `Generate` + +`Rasuvaeff\PropertyTesting\Generate` + +**Class** — **Package:** [property-testing-core](https://github.com/rasuvaeff/property-testing-core) — [Source](https://github.com/rasuvaeff/property-testing-core/blob/master/src/Generate.php#L32) — **Version:** working tree + +The generator of one parameter, written on the parameter itself — read by +Gen::forParameters() and Gen::forClass(), so by every adapter's +signature-driven mode, before the docblock and the native type. + +```php +public function delayStaysWithinCap( + #[Generate(new IntArbitrary(0, 10_000))] int $base, + #[Generate([Generators::class, 'email'])] string $to, +): void +``` + +Either an arbitrary, built with `new` (an attribute argument is a constant +expression, so `Gen::*` factories cannot appear in it), or a reference to a +static factory that returns one: a method name of the declaring class, +`'Class::method'`, `[Class::class, 'method']`, or an invokable object. +The reference is how a generator that needs a closure — `Gen::map()`, +`Gen::email()`, `Gen::regex()` — reaches a parameter on PHP 8.3 and 8.4. + +The attribute is exact: it wins over the docblock and the native type, and +a nullable parameter is not made nullable on top of it. + +## Constructor + +```php +__construct( + \ArbitraryInterface|non-empty-string|array{0: class-string|object, 1: non-empty-string}|object $generator, +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$generator` | `\ArbitraryInterface|non-empty-string|array{0: class-string|object, 1: non-empty-string}|object` | *required* | The arbitrary, or a static factory returning one. | + diff --git a/docs/src/api/classes/Runner/PropertyRunner.md b/docs/src/api/classes/Runner/PropertyRunner.md index 03fb1a0..8f1b067 100644 --- a/docs/src/api/classes/Runner/PropertyRunner.md +++ b/docs/src/api/classes/Runner/PropertyRunner.md @@ -9,7 +9,7 @@ description: "The framework-agnostic property engine: runs the explicit examples `Rasuvaeff\PropertyTesting\Runner\PropertyRunner` -**Class** — **Package:** [property-testing-core](https://github.com/rasuvaeff/property-testing-core) — [Source](https://github.com/rasuvaeff/property-testing-core/blob/master/src/Runner/PropertyRunner.php#L62) — **Version:** working tree +**Class** — **Package:** [property-testing-core](https://github.com/rasuvaeff/property-testing-core) — [Source](https://github.com/rasuvaeff/property-testing-core/blob/master/src/Runner/PropertyRunner.php#L63) — **Version:** working tree The framework-agnostic property engine: runs the explicit examples, replays the regression corpus, generates random inputs until the required number of diff --git a/docs/src/api/index.md b/docs/src/api/index.md index 34c6a00..a6ec126 100644 --- a/docs/src/api/index.md +++ b/docs/src/api/index.md @@ -69,6 +69,7 @@ Generated by reflection (`docs/scripts/reflect-api.php`) over all three packages | [`ExampleViolationException`](/api/classes/ExampleViolationException) | class | Reported when an explicit example (a fixed input declared via the property's `Examples` method)… | | [`GaveUpException`](/api/classes/GaveUpException) | class | Thrown (as the failure of a property) when discarded inputs — or runs the environment refused —… | | [`Gen`](/api/classes/Gen) | class | Facade with static factories for the built-in ArbitraryInterfaces. | +| [`Generate`](/api/classes/Generate) | class | The generator of one parameter, written on the parameter itself — read by Gen::forParameters() and… | | [`GenerationExhaustedException`](/api/classes/GenerationExhaustedException) | class | Thrown when a bounded-attempt generator cannot produce a value that satisfies its constraint… | | [`PathViolationException`](/api/classes/PathViolationException) | class | Reported when a run pinned to a shrink path cannot follow it: the candidate a step names is gone,… | | [`PropertyId`](/api/classes/PropertyId) | class | What a property's id has to be good for, and how to tell when it is not. | diff --git a/llms.txt b/llms.txt index 07bc020..baadb52 100644 --- a/llms.txt +++ b/llms.txt @@ -352,17 +352,21 @@ Gen::swarm(ArbitraryInterface $arbitrary) // : swarm testing. Wraps a CHOICE // (upper bound), EdgeCased — the wrappers answer null over an unbounded source and their enumerate() // throws LogicException. Order: ints ascending, oneOf as listed, null first, first component slowest. Gen::forClass(string $class, array $overrides = [], bool $skipInvalid = false, int $maxDepth = 3) -// : instances from the constructor. Per parameter, in order: override, docblock psalm -// type, native type. Docblock spellings: @psalm-param / @phpstan-param win over @param; a +// : instances from the constructor. Per parameter, in order: override, #[Generate] on the +// parameter, docblock psalm type, native type. Docblock spellings: @psalm-param / @phpstan-param win over @param; a // @var (@psalm-var, @phpstan-var) on the PROMOTED PROPERTY is read when the constructor // docblock says nothing about that parameter. Reads int<0,100> | positive-int | -// non-negative-int | non-empty-string | list | non-empty-list | T[] | array | +// non-negative-int | non-empty-string | numeric-string | non-falsy-string (truthy-string) | +// lowercase-string | non-empty-lowercase-string | list | non-empty-list | T[] | array | +// non-empty-array | array{a: T, b?: U, 'quoted key': V, 3: W} (optional key: SOMETIMES ABSENT, +// shrinks towards absent; absent is not null) | array{T, U} | list{T, U} | array{} | // 'a'|'b' | 1|2|3 | 'a'|null | A|B | ?T | enum | DateTimeImmutable | another class (recursive, // maxDepth, cycles refused by name). Native float -> floatBetween(-1e6, 1e6). Class names // inside docblock types (list, Status|null, \DateTimeImmutable) resolve through the // declaring file's namespace and `use` imports, like the code beneath the docblock. ANYTHING ELSE // (bare array, mixed, callable, an untyped parameter, a NATIVE UNION such as int|string, a -// variadic, a docblock type outside this subset such as float<0.0, 1.0> or array{a: int}, an +// variadic, a docblock type outside this subset such as float<0.0, 1.0> or an unsealed +// array{a: int, ...} / a "double-quoted" shape key / list{a: int}, an // unknown class name — the message says `unknown class "Nope"`) THROWS naming the parameter and // the type — never a widened guess; the one fallback is a native CLASS type whose docblock only // adds generics (Collection). An override key naming no parameter THROWS; an enum without @@ -370,13 +374,27 @@ Gen::forClass(string $class, array $overrides = [], bool $skipInvalid = false, i // A constructor that rejects a value throws by default (the generator does not match the domain); // skipInvalid: true discards and redraws through Gen::filter (100 attempts -> GenerationExhaustedException), // discarding Exceptions only, never Errors. Shrinks through the arguments, rebuilding the instance. -Gen::forParameters(\ReflectionFunctionAbstract $function, array $overrides = [], int $maxDepth = 3) +Gen::forParameters(\ReflectionFunctionAbstract $function, array $overrides = [], int $maxDepth = 3, bool $derive = true) // NOT an arbitrary — returns array for the function's parameters, by // name in signature order. The forClass rules applied to any method or closure: per parameter, -// override -> docblock psalm type (@psalm-param/@phpstan-param over @param) -> native type; same +// override -> #[Generate] -> docblock psalm type (@psalm-param/@phpstan-param over @param) -> native type; same // supported subset, same refusals (naming the function and the parameter, never a widened guess). Overrides may be PARTIAL: named parameters // are taken as given, the rest derived from the signature. No skipInvalid (nothing is executed); // pure function of reflection, Random not involved. Engine half of the adapters' auto mode. +// derive: false -> only overrides and #[Generate]; a parameter with neither THROWS +// ("has no generator; pass an override or #[Generate]") — the adapters' mode without auto. + +#[Generate(ArbitraryInterface|string|array|object $generator)] // on a PARAMETER (Rasuvaeff\PropertyTesting\Generate) +// Read by forParameters()/forClass() after an override, before docblock and native type. +// Argument: an arbitrary built with `new` (constant expression — no Gen::* calls, no closures on +// 8.3/8.4: `new IntArbitrary(0, 100)`, `new OneOfArbitrary('a', 'b')`, +// `new ArrayArbitrary(new IntArbitrary(0, 9), 1, 10)`), OR a static factory returning one: +// 'method' (declaring class first), 'Class::method', [Class::class, 'method'], invokable object. +// Use the reference for map/filter/flatMap/composite/email/url/json/regex/recursive. +// Exact: wins over the docblock (even an unreadable one), never made nullable on ?T; counts as +// the override on a variadic. Not repeatable. A broken one (rejected constructor arguments, +// non-static method, non-callable, factory returning a non-arbitrary or throwing) THROWS naming +// the parameter. Test code only — on a production constructor it is a runtime dependency. Gen::ipv4() Gen::ipv6() Gen::email() Gen::url() // ipv6: canonical RFC 5952 text, shrinks to "::" Gen::json(int $maxDepth = 3) Gen::jsonString(int $maxDepth = 3) Gen::regex(string $pattern, int $maxRepeat = 8) // regex SUBSET compiled to combinators; diff --git a/resources/skills/rasuvaeff-property-testing-core/SKILL.md b/resources/skills/rasuvaeff-property-testing-core/SKILL.md index 14d8748..b751916 100644 --- a/resources/skills/rasuvaeff-property-testing-core/SKILL.md +++ b/resources/skills/rasuvaeff-property-testing-core/SKILL.md @@ -189,8 +189,9 @@ rejected at construction. | SUT takes a `Random\Randomizer` (jitter, shuffle) | `Gen::randomizer()` / `Gen::randomEngine()` — or just type the parameter `\Random\Randomizer` under `auto` | The SUT's random decisions ride the draw tape and SHRINK toward `"\0"` (`getInt` → `$min`); a fixed seed only reproduces | | Constant (for commands) | `Gen::constant($value)` | | | Stateful without a class per command | `Gen::rules(Machine::class, maxLength: 50)` | See Stateful below | -| VO/config from its constructor | `Gen::forClass(Money::class, $overrides)` | Per parameter: override → `@param` psalm type (`int<0, 100>` beats `int`) → native type; unreadable types THROW naming the parameter, never a widened guess; `skipInvalid: true` discards constructor-rejected values | +| VO/config from its constructor | `Gen::forClass(Money::class, $overrides)` | Per parameter: override → `#[Generate]` → `@param` psalm type (`int<0, 100>` beats `int`; also `numeric-string`, `lowercase-string`, `non-falsy-string`, `array{a: T, b?: U}` with optional keys sometimes absent) → native type; unreadable types THROW naming the parameter, never a widened guess; `skipInvalid: true` discards constructor-rejected values | | Generators from any signature | `Gen::forParameters($reflectionFn, $overrides)` | The forClass rules for a method/closure's parameters, returned as `array` in signature order; overrides may be PARTIAL — named params taken as given, rest derived | +| A parameter whose generator a type cannot say | `#[Generate(new IntArbitrary(0, 100))]` on the parameter, or `#[Generate([Generators::class, 'email'])]` for anything built with a closure | Read by `forParameters`/`forClass` after an override, before the docblock; exact (no extra nullable); `derive: false` makes override-or-attribute mandatory per parameter. Test code only | Numeric generators are **boundary-biased**: ~1 in 5 draws returns an in-range edge value. Sized generators never go below their minimum. If the body diff --git a/src/Gen.php b/src/Gen.php index 210de8a..56f4c7c 100644 --- a/src/Gen.php +++ b/src/Gen.php @@ -374,7 +374,8 @@ public static function oneOf(mixed ...$values): ArbitraryInterface * Gen::forClass(Money::class, ['amount' => Gen::intPositive()]); * ``` * - * Per parameter, in order: an override, then the docblock (psalm subset; + * Per parameter, in order: an override, then a {@see Generate} attribute + * on the parameter, then the docblock (psalm subset; * `@psalm-param`/`@phpstan-param` over `@param`, and the promoted * property's own `@var` when the constructor docblock is silent), then * the native type. The docblock wins over the native type because it says @@ -430,7 +431,14 @@ public static function forClass( * @param \ReflectionFunctionAbstract $function The function, method or closure whose parameters to read. * @param array $overrides Generators by parameter name, winning over * anything the parameter declares. + * With `$derive: false` nothing is read from the declared types: every + * parameter needs an override or a {@see Generate} attribute, and one + * without either is refused. That is an adapter's mode without `auto` — + * generators stated explicitly, some in a provider, some on the parameters. + * * @param int $maxDepth How deep to follow class-typed parameters before refusing. + * @param bool $derive Whether a parameter with neither an override nor + * a {@see Generate} attribute is derived from its declared type. * * @return array By parameter name, in signature order. */ @@ -438,6 +446,7 @@ public static function forParameters( \ReflectionFunctionAbstract $function, array $overrides = [], int $maxDepth = 3, + bool $derive = true, ): array { $name = $function instanceof \ReflectionMethod ? $function->getDeclaringClass()->getName() . '::' . $function->getName() @@ -449,6 +458,7 @@ public static function forParameters( $overrides, $maxDepth, [], + $derive, ); } diff --git a/src/Generate.php b/src/Generate.php new file mode 100644 index 0000000..0c7bda9 --- /dev/null +++ b/src/Generate.php @@ -0,0 +1,41 @@ +getDocComment(); if ($docblock !== false) { + // One line, without the comment's frame: a shape is as often + // written across lines (`array{\n * a: int,\n * }`) as on one. + $flat = str_replace(["\r\n", "\n", "\r"], ' ', (string) preg_replace( + ['~^\s*/\*\*~', '~\*/\s*\z~', '~^\s*\*~m'], + '', + $docblock, + )); + foreach (self::PARAM_TAGS as $tag) { // `@param $name` — the type is everything between the tag // and the variable, which is what keeps `array` in - // one piece. - if (preg_match_all('/@' . $tag . '\s+(?.+?)\s+\$(?[A-Za-z_][A-Za-z0-9_]*)/', $docblock, $matches, PREG_SET_ORDER) === false) { + // one piece. It never holds an `@`, so a tag without a + // variable cannot swallow the tag after it. + if (preg_match_all('/@' . $tag . '\s+(?[^@]+?)\s+\$(?[A-Za-z_][A-Za-z0-9_]*)/', $flat, $matches, PREG_SET_ORDER) === false) { continue; } diff --git a/src/Internal/ParameterGenerators.php b/src/Internal/ParameterGenerators.php index 9b3e779..020ed5c 100644 --- a/src/Internal/ParameterGenerators.php +++ b/src/Internal/ParameterGenerators.php @@ -7,6 +7,7 @@ use Closure; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; +use Rasuvaeff\PropertyTesting\Generate; /** * The parameters of one signature, as generators. @@ -15,7 +16,8 @@ * constructor's parameters, instantiated) and * {@see \Rasuvaeff\PropertyTesting\Gen::forParameters()} (any function's * parameters, handed back as a map). The resolution rules are one thing — - * an explicit override, then the docblock ({@see DocblockTypes}), then the native type — + * an explicit override, then a {@see Generate} attribute on the parameter, then + * the docblock ({@see DocblockTypes}), then the native type — * and $subject is the only difference between the callers: it names whose * parameter a refusal is about. * @@ -72,6 +74,8 @@ public static function forConstructor(string $class, array $overrides, int $maxD * @param string $subject Whose parameters these are, for the refusal messages. * @param array $overrides * @param list $chain + * @param bool $derive Whether a parameter with neither an override nor a + * {@see Generate} attribute is derived from its declared type, or refused. * * @return array */ @@ -81,6 +85,7 @@ public static function forSignature( array $overrides, int $maxDepth, array $chain, + bool $derive = true, ): array { $documented = DocblockTypes::of($function); $resolveClass = self::classResolver($function); @@ -108,9 +113,25 @@ public static function forSignature( continue; } + $attributed = self::fromAttribute($subject, $parameter); + + if ($attributed instanceof ArbitraryInterface) { + $shape[$name] = $attributed; + + continue; + } + if ($parameter->isVariadic()) { throw new \InvalidArgumentException(sprintf( - 'Cannot generate %s: parameter $%s is variadic; pass an override', + 'Cannot generate %s: parameter $%s is variadic; pass an override or #[Generate]', + $subject, + $name, + )); + } + + if (!$derive) { + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: parameter $%s has no generator; pass an override or #[Generate]', $subject, $name, )); @@ -122,6 +143,100 @@ public static function forSignature( return $shape; } + /** + * The generator a {@see Generate} attribute on $parameter names, or null + * when it carries none. Every way the attribute can be wrong — arguments + * its constructor or PHP rejects, a reference that is not a static + * factory, a factory returning something else — is a refusal naming the + * parameter: the throw happens when the attribute is read, far from where + * it was written. + */ + private static function fromAttribute(string $subject, \ReflectionParameter $parameter): ?ArbitraryInterface + { + $attributes = $parameter->getAttributes(Generate::class); + + if ($attributes === []) { + return null; + } + + try { + $generator = $attributes[0]->newInstance()->generator; + } catch (\Throwable $failure) { + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: #[Generate] on parameter $%s cannot be built: %s', + $subject, + $parameter->getName(), + $failure->getMessage(), + ), 0, $failure); + } + + if ($generator instanceof ArbitraryInterface) { + return $generator; + } + + $factory = self::factory($subject, $parameter, $generator); + + try { + /** @var mixed $built */ + $built = $factory(); + } catch (\Throwable $failure) { + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: #[Generate] on parameter $%s names a factory that threw: %s', + $subject, + $parameter->getName(), + $failure->getMessage(), + ), 0, $failure); + } + + if (!$built instanceof ArbitraryInterface) { + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: #[Generate] on parameter $%s names a factory that returned %s, not an ArbitraryInterface', + $subject, + $parameter->getName(), + get_debug_type($built), + )); + } + + return $built; + } + + /** + * The static factory a {@see Generate} reference denotes. A bare method + * name is looked up on the declaring class first, the way a provider name + * is, so a local factory wins over a global function of the same name. + */ + private static function factory(string $subject, \ReflectionParameter $parameter, array|object|string $reference): Closure + { + $class = $parameter->getDeclaringClass(); + + if (is_string($reference) && $class instanceof \ReflectionClass && $class->hasMethod($reference)) { + $method = $class->getMethod($reference); + + if (!$method->isStatic()) { + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: #[Generate] on parameter $%s names %s::%s(), which is not static', + $subject, + $parameter->getName(), + $class->getName(), + $reference, + )); + } + + return $method->getClosure(); + } + + if (is_callable($reference)) { + return Closure::fromCallable($reference); + } + + throw new \InvalidArgumentException(sprintf( + 'Cannot generate %s: #[Generate] on parameter $%s takes an ArbitraryInterface or a static factory returning one, got %s', + $subject, + $parameter->getName(), + is_string($reference) ? sprintf('"%s"', $reference) : get_debug_type($reference), + )); + } + /** * The class a name written in $function's docblock denotes, or null. * @@ -254,7 +369,7 @@ private static function generatorFor( // narrow its generics (`Collection`), never its values. if (!$native instanceof \ReflectionNamedType || $native->isBuiltin()) { throw new \InvalidArgumentException(sprintf( - 'Cannot generate %s: parameter $%s is documented as %s, which this cannot read%s; pass an override', + 'Cannot generate %s: parameter $%s is documented as %s, which this cannot read%s; pass an override or #[Generate]', $subject, $parameter->getName(), $documented, @@ -274,7 +389,7 @@ private static function generatorFor( } throw new \InvalidArgumentException(sprintf( - 'Cannot generate %s: parameter $%s is %s, which this cannot read; pass an override', + 'Cannot generate %s: parameter $%s is %s, which this cannot read; pass an override or #[Generate]', $subject, $parameter->getName(), match (true) { diff --git a/src/Internal/TypeGenerators.php b/src/Internal/TypeGenerators.php index 26e09cd..a573f1f 100644 --- a/src/Internal/TypeGenerators.php +++ b/src/Internal/TypeGenerators.php @@ -6,6 +6,7 @@ use Closure; use Rasuvaeff\PropertyTesting\ArbitraryInterface; +use Rasuvaeff\PropertyTesting\Draw; use Rasuvaeff\PropertyTesting\Gen; /** @@ -83,13 +84,19 @@ public static function fromDocblock(string $type, Closure $forClass, Closure $re return $union; } + $shape = self::shape($type, $forClass, $resolveClass); + + if ($shape instanceof ArbitraryInterface) { + return $shape; + } + return self::classType($type, $forClass, $resolveClass); } /** * A class, interface or enum named the way the docblock's file names it * (`LineItem`, `Order\LineItem`, `\App\LineItem`). What is not a name at - * all (`array{a: int}`, `numeric-string`) or names nothing the resolver + * all (`float<0.0, 1.0>`, `callable-string`) or names nothing the resolver * knows stays unread. * * @param Closure(string): ArbitraryInterface $forClass @@ -142,6 +149,15 @@ private static function simple(string $type): ?ArbitraryInterface 'float' => Gen::floatBetween(-1_000_000.0, 1_000_000.0), 'string' => Gen::string(), 'non-empty-string' => Gen::stringOf(1, 100), + // Non-empty and not "0" — the one string PHP casts to false + // besides "", and the reason the type exists. + 'non-falsy-string', 'truthy-string' => Gen::filter( + Gen::stringOf(1, 100), + static fn(string $value): bool => $value !== '0', + ), + 'lowercase-string' => Gen::map(Gen::string(), mb_strtolower(...)), + 'non-empty-lowercase-string' => Gen::map(Gen::stringOf(1, 100), mb_strtolower(...)), + 'numeric-string' => self::numericString(), 'bool' => Gen::bool(), 'true' => Gen::constant(value: true), 'false' => Gen::constant(value: false), @@ -150,6 +166,141 @@ private static function simple(string $type): ?ArbitraryInterface }; } + /** + * Strings `is_numeric()` accepts, weighted towards the forms that surprise + * code written for `"42"`: a sign, an exponent, a bare or trailing dot, + * and the whitespace PHP 8 tolerates on either side. + * + * @return ArbitraryInterface + */ + private static function numericString(): ArbitraryInterface + { + return Gen::composite(static function (Draw $draw): string { + $form = $draw->draw(Gen::frequency([ + [4, Gen::constant('integer')], + [2, Gen::constant('float')], + [2, Gen::constant('exponent')], + [1, Gen::constant('dot')], + [2, Gen::constant('whitespace')], + ])); + + return match ($form) { + 'integer' => (string) $draw->draw(Gen::int()), + 'float' => (string) $draw->draw(Gen::floatBetween(-1_000_000.0, 1_000_000.0)), + 'exponent' => sprintf( + '%de%d', + $draw->draw(Gen::intBetween(-999, 999)), + $draw->draw(Gen::intBetween(-20, 20)), + ), + 'dot' => sprintf( + $draw->draw(Gen::bool()) ? '%s.%d' : '%s%d.', + $draw->draw(Gen::elements(['', '+', '-'])), + $draw->draw(Gen::intBetween(0, 999)), + ), + default => self::padded( + (string) $draw->draw(Gen::intBetween(-999, 999)), + $draw->draw(Gen::elements([' ', "\t", "\n", "\r", "\v", "\f"])), + $draw->draw(Gen::bool()), + ), + }; + }); + } + + private static function padded(string $number, string $whitespace, bool $leading): string + { + return $leading ? $whitespace . $number : $number . $whitespace; + } + + /** + * `array{a: int, b?: string}`, `array{int, string}`, `list{int, string}`, + * `array{'quoted key': int}`. An optional key is left out of some values, + * and shrinks towards being left out — absent is not null, so a nullable + * value type keeps its own null. An unsealed shape (`...`) and a key form + * outside identifiers, integers and single-quoted strings stay unread. + * + * @param Closure(string): ArbitraryInterface $forClass + * @param Closure(string): ?string $resolveClass + */ + private static function shape(string $type, Closure $forClass, Closure $resolveClass): ?ArbitraryInterface + { + if (preg_match('/^(array|list)\{(.*)\}\z/s', $type, $matches) !== 1) { + return null; + } + + $isList = $matches[1] === 'list'; + $elements = self::splitArguments($matches[2]); + + if (end($elements) === '') { + // A trailing comma, or the empty shape. + array_pop($elements); + } + + $keys = []; + $values = []; + $optional = []; + $position = 0; + + foreach ($elements as $element) { + if (preg_match("/^(?[A-Za-z_][A-Za-z0-9_]*|-?\d+|'(?:[^'\\\\]|\\\\.)*')(?\?)?\s*:\s*(?.+)\z/s", $element, $field) === 1) { + if ($isList) { + return null; + } + + $key = self::shapeKey($field['key']); + $elementType = $field['type']; + $isOptional = $field['optional'] === '?'; + } else { + $key = $position++; + $elementType = $element; + $isOptional = false; + } + + $value = self::fromDocblock($elementType, $forClass, $resolveClass); + + if (!$value instanceof ArbitraryInterface) { + return null; + } + + $keys[] = $key; + $values[] = $value; + $optional[] = $isOptional; + } + + if ($values === []) { + return Gen::constant([]); + } + + return Gen::composite(static function (Draw $draw) use ($keys, $values, $optional): array { + $drawn = array_map($draw->draw(...), $values); + // Drawn after every value, so a candidate that drops a key replays + // the value draws unchanged. + $present = array_filter(array_map( + static fn(bool $isOptional): bool => !$isOptional || $draw->draw(Gen::bool()), + $optional, + )); + + return array_combine( + array_values(array_intersect_key($keys, $present)), + array_values(array_intersect_key($drawn, $present)), + ); + }); + } + + /** + * A shape key as PHP stores it: an integer literal or a numeric quoted + * key becomes an integer key, as it would in an array literal. + */ + private static function shapeKey(string $written): int|string + { + if (str_starts_with($written, "'")) { + $written = (string) preg_replace('/\\\\(.)/', '$1', substr($written, 1, -1)); + } + + $integer = (int) $written; + + return (string) $integer === $written ? $integer : $written; + } + /** * `int<0, 100>`, `int`, `int<0, max>`. */ @@ -285,7 +436,8 @@ private static function literals(array $members): ?array } /** - * Splits `K, V` without cutting inside a nested `<…>` or a quoted literal. + * Splits `K, V` without cutting inside a nested `<…>`, a shape's `{…}` + * or a quoted literal. * * @return list */ @@ -337,9 +489,9 @@ private static function split(string $subject, string $separator): array continue; } - if ($character === '<') { + if ($character === '<' || $character === '{') { ++$depth; - } elseif ($character === '>') { + } elseif ($character === '>' || $character === '}') { --$depth; } diff --git a/src/Runner/Redis/RedisDsn.php b/src/Runner/Redis/RedisDsn.php index 574ac30..48cb8da 100644 --- a/src/Runner/Redis/RedisDsn.php +++ b/src/Runner/Redis/RedisDsn.php @@ -184,7 +184,7 @@ public static function parse(string $dsn, ?string $password = null): self $query = []; - if (is_array($parts) && isset($parts['query'])) { + if (isset($parts['query'])) { parse_str($parts['query'], $query); } diff --git a/tests/Arbitrary/ClassArbitraryTest.php b/tests/Arbitrary/ClassArbitraryTest.php index ba34c90..1c02f8c 100644 --- a/tests/Arbitrary/ClassArbitraryTest.php +++ b/tests/Arbitrary/ClassArbitraryTest.php @@ -328,7 +328,7 @@ public function aDocblockTypeItCannotReadOnAScalarIsRefusedNotWidened(): void } catch (\InvalidArgumentException $e) { Assert::same( $e->getMessage(), - 'Cannot generate ' . NarrowedFloat::class . ': parameter $ratio is documented as float<0.0, 1.0>, which this cannot read; pass an override', + 'Cannot generate ' . NarrowedFloat::class . ': parameter $ratio is documented as float<0.0, 1.0>, which this cannot read; pass an override or #[Generate]', ); } } diff --git a/tests/GenerateAttributeTest.php b/tests/GenerateAttributeTest.php new file mode 100644 index 0000000..1dfed61 --- /dev/null +++ b/tests/GenerateAttributeTest.php @@ -0,0 +1,203 @@ +generate($random)->value, 5); + Assert::same($generators['even']->generate($random)->value % 2, 0); + } + } + + public function anOverrideWinsOverTheAttribute(): void + { + $generators = Gen::forParameters(self::method('onEveryParameter'), ['fixed' => Gen::constant(8)]); + + Assert::same($generators['fixed']->generate(new Random(1))->value, 8); + } + + public function theAttributeWinsOverAReadableDocblock(): void + { + $generators = Gen::forParameters(self::method('overAReadableDocblock')); + + Assert::same($generators['number']->generate(new Random(1))->value, 1); + } + + public function theAttributeStandsInForAnUnreadableDocblock(): void + { + // Without the attribute `float<0.0, 1.0>` is refused; with it, the + // docblock is never read. + $generators = Gen::forParameters(self::method('overAnUnreadableDocblock')); + $random = new Random(2); + + for ($i = 0; $i < 30; ++$i) { + $ratio = $generators['ratio']->generate($random)->value; + + Assert::true($ratio >= 0.0 && $ratio <= 1.0); + } + } + + public function aNullableParameterIsNotMadeNullableOnTopOfTheAttribute(): void + { + $generators = Gen::forParameters(self::method('onANullable')); + $random = new Random(3); + + for ($i = 0; $i < 100; ++$i) { + Assert::same($generators['number']->generate($random)->value, 3); + } + } + + public function anAttributeOnAVariadicCountsAsItsOverride(): void + { + $generators = Gen::forParameters(self::method('onAVariadic')); + + Assert::same($generators['numbers']->generate(new Random(1))->value, 7); + } + + #[DataProvider('references')] + public function everyReferenceFormResolvesToItsFactory(string $method, \Closure $expected): void + { + $generators = Gen::forParameters(self::method($method)); + $random = new Random(4); + + for ($i = 0; $i < 20; ++$i) { + Assert::true($expected($generators['number']->generate($random)->value)); + } + } + + /** + * @return iterable + */ + public static function references(): iterable + { + $odd = static fn(mixed $value): bool => is_int($value) && $value % 2 === 1; + + yield 'array callable' => ['arrayReference', $odd]; + yield 'Class::method string' => ['stringReference', $odd]; + yield 'invokable object' => ['invokable', static fn(mixed $value): bool => $value === 9]; + } + + public function forClassReadsTheAttributeOnAConstructor(): void + { + $random = new Random(5); + $arbitrary = Gen::forClass(AttributedValue::class); + + for ($i = 0; $i < 20; ++$i) { + Assert::same($arbitrary->generate($random)->value->answer, 42); + } + } + + public function withoutDerivationEveryParameterNeedsAGenerator(): void + { + $generators = Gen::forParameters(self::method('onEveryParameter'), derive: false); + + Assert::same(array_keys($generators), ['fixed', 'even']); + } + + public function withoutDerivationAnOverrideFillsTheGap(): void + { + $generators = Gen::forParameters( + self::method('onSomeParameters'), + ['plain' => Gen::constant(4)], + derive: false, + ); + + Assert::same(array_keys($generators), ['attributed', 'plain']); + Assert::same($generators['plain']->generate(new Random(1))->value, 4); + } + + public function withoutDerivationAParameterWithNoGeneratorIsRefused(): void + { + $this->assertRefused(static fn(): array => Gen::forParameters(self::method('onSomeParameters'), derive: false), sprintf( + 'Cannot generate arguments for %s::onSomeParameters(): parameter $plain has no generator; pass an override or #[Generate]', + GenerateMethods::class, + )); + } + + public function withDerivationTheRestComesFromTheSignature(): void + { + $generators = Gen::forParameters(self::method('onSomeParameters')); + + Assert::true(is_int($generators['plain']->generate(new Random(1))->value)); + } + + #[DataProvider('refusals')] + public function aBrokenAttributeIsRefusedNamingTheParameter(string $method, string $reason): void + { + $this->assertRefused(static fn(): array => Gen::forParameters(self::method($method)), sprintf('Cannot generate arguments for %s::%s(): #[Generate] on parameter $number %s', GenerateMethods::class, $method, $reason)); + } + + /** + * @return iterable + */ + public static function refusals(): iterable + { + yield 'constructor rejects its arguments' => ['invalidArguments', 'cannot be built: Min must be less than or equal to max']; + yield 'repeated' => ['repeated', sprintf('cannot be built: Attribute "%s" must not be repeated', Generate::class)]; + yield 'non-static factory' => ['nonStaticFactory', sprintf('names %s::notStatic(), which is not static', GenerateMethods::class)]; + yield 'array that is no callable' => ['notACallableArray', 'takes an ArbitraryInterface or a static factory returning one, got array']; + yield 'not a factory' => ['notAFactory', 'takes an ArbitraryInterface or a static factory returning one, got "noSuchFactory"']; + yield 'factory returns no arbitrary' => ['returnsNoArbitrary', 'names a factory that returned int, not an ArbitraryInterface']; + yield 'factory throws' => ['factoryThrows', 'names a factory that threw: factory failed']; + } + + public function aBrokenAttributeKeepsTheCauseAsPrevious(): void + { + try { + Gen::forParameters(self::method('factoryThrows')); + } catch (\InvalidArgumentException $refusal) { + Assert::instanceOf($refusal->getPrevious(), \LogicException::class); + + return; + } + + Assert::fail('expected an InvalidArgumentException'); + } + + private static function method(string $name): \ReflectionMethod + { + return new \ReflectionMethod(GenerateMethods::class, $name); + } + + private function assertRefused(\Closure $call, string $message): void + { + try { + $call(); + } catch (\InvalidArgumentException $refusal) { + Assert::same($refusal->getMessage(), $message); + + return; + } + + Assert::fail('expected an InvalidArgumentException'); + } +} diff --git a/tests/Internal/RefinedTypesTest.php b/tests/Internal/RefinedTypesTest.php new file mode 100644 index 0000000..e0811da --- /dev/null +++ b/tests/Internal/RefinedTypesTest.php @@ -0,0 +1,269 @@ + 0, 'dot' => 0, 'leading space' => 0, 'trailing space' => 0, 'sign' => 0]; + + foreach ($this->draws('strings', 'numeric') as $value) { + Assert::true(is_string($value) && is_numeric($value)); + + $forms['exponent'] += (int) str_contains($value, 'e'); + $forms['dot'] += (int) (str_starts_with(ltrim($value, '+-'), '.') || str_ends_with($value, '.')); + $forms['leading space'] += (int) (ltrim($value) !== $value); + $forms['trailing space'] += (int) (rtrim($value) !== $value); + $forms['sign'] += (int) str_starts_with($value, '+'); + } + + foreach ($forms as $form => $count) { + Assert::true($count > 0, sprintf('form "%s" never drawn', $form)); + } + } + + #[DataProvider('nonFalsyParameters')] + public function aNonFalsyStringIsNeverEmptyNorZero(string $parameter): void + { + foreach ($this->draws('strings', $parameter) as $value) { + Assert::true(is_string($value) && $value !== '' && $value !== '0'); + } + } + + /** + * @return iterable + */ + public static function nonFalsyParameters(): iterable + { + yield 'non-falsy-string' => ['nonFalsy']; + yield 'truthy-string' => ['truthy']; + } + + public function aLowercaseStringHasNoUppercaseUnderEitherDefinition(): void + { + $nonEmpty = 0; + + foreach ($this->draws('strings', 'lower') as $value) { + Assert::true(is_string($value)); + Assert::same(strtolower($value), $value); + Assert::same(mb_strtolower($value), $value); + + $nonEmpty += (int) ($value !== ''); + } + + Assert::true($nonEmpty > 0); + } + + public function aNonEmptyLowercaseStringIsNeverEmpty(): void + { + foreach ($this->draws('strings', 'nonEmptyLower') as $value) { + Assert::true(is_string($value) && $value !== ''); + Assert::same(mb_strtolower($value), $value); + } + } + + public function aKeyedShapeHasItsRequiredKeysAndSometimesTheOptionalOne(): void + { + $withName = 0; + $withoutName = 0; + + foreach ($this->draws('shapes', 'keyed') as $value) { + Assert::true(is_array($value)); + + $keys = array_keys($value); + $hasName = array_key_exists('name', $value); + + Assert::same($keys, $hasName ? ['id', 'name', 'with space', 3] : ['id', 'with space', 3]); + Assert::true(is_int($value['id']) && $value['id'] >= 1); + Assert::true(is_bool($value['with space'])); + Assert::true(is_int($value[3]) && $value[3] >= 0 && $value[3] <= 9); + + if ($hasName) { + Assert::true(is_string($value['name']) && $value['name'] !== ''); + ++$withName; + } else { + ++$withoutName; + } + } + + Assert::true($withName > 0 && $withoutName > 0); + } + + public function aPositionalShapeIsAList(): void + { + foreach ($this->draws('shapes', 'positional') as $value) { + Assert::true(is_array($value) && array_is_list($value) && count($value) === 2); + Assert::true(is_int($value[0]) && $value[0] >= 0 && $value[0] <= 9); + Assert::true(in_array($value[1], ['a', 'b'], strict: true)); + } + } + + public function aListShapeIsAList(): void + { + foreach ($this->draws('shapes', 'list') as $value) { + Assert::true(is_array($value) && array_is_list($value)); + Assert::true(is_bool($value[0])); + Assert::same($value[1], 1); + } + } + + public function theEmptyShapeIsTheEmptyArray(): void + { + foreach ($this->draws('shapes', 'empty') as $value) { + Assert::same($value, []); + } + } + + public function absentIsNotNull(): void + { + $absent = 0; + $null = 0; + + foreach ($this->draws('shapes', 'optionalNullable') as $value) { + Assert::true(is_array($value)); + + if (!array_key_exists('a', $value)) { + ++$absent; + } elseif ($value['a'] === null) { + ++$null; + } + } + + Assert::true($absent > 0 && $null > 0); + } + + public function aShapeNestsInsideACollection(): void + { + foreach ($this->draws('shapes', 'nested') as $value) { + Assert::true(is_array($value)); + + foreach ($value as $key => $entry) { + Assert::true(is_string($key)); + Assert::true(is_array($entry) && array_keys($entry) === ['a', 'b']); + } + } + } + + public function aShapeIsAMemberOfAUnion(): void + { + $nulls = 0; + $shapes = 0; + + foreach ($this->draws('shapes', 'shapeOrNull') as $value) { + if ($value === null) { + ++$nulls; + + continue; + } + + Assert::true(is_array($value) && array_keys($value) === ['a']); + ++$shapes; + } + + Assert::true($nulls > 0 && $shapes > 0); + } + + public function aShapeWrittenAcrossLinesIsRead(): void + { + foreach ($this->draws('shapes', 'multiline') as $value) { + Assert::true(is_array($value) && array_keys($value) === ['a', 'b']); + } + } + + public function anOptionalKeyShrinksTowardsAbsent(): void + { + $arbitrary = $this->generator('shapes', 'keyed'); + $random = new Random(11); + + do { + $tree = $arbitrary->generate($random); + } while (!array_key_exists('name', (array) $tree->value)); + + $dropsTheKey = false; + + foreach ($tree->shrinks() as $candidate) { + $dropsTheKey = $dropsTheKey || !array_key_exists('name', (array) $candidate->value); + } + + Assert::true($dropsTheKey); + } + + #[DataProvider('unreadableShapes')] + public function aShapeFormOutsideTheSubsetIsRefused(string $method, string $type): void + { + try { + Gen::forParameters(new \ReflectionMethod(RefinedTypeMethods::class, $method)); + } catch (\InvalidArgumentException $refusal) { + Assert::same($refusal->getMessage(), sprintf( + 'Cannot generate arguments for %s::%s(): parameter $shape is documented as %s, which this cannot read; pass an override or #[Generate]', + RefinedTypeMethods::class, + $method, + $type, + )); + + return; + } + + Assert::fail('expected an InvalidArgumentException'); + } + + /** + * @return iterable + */ + public static function unreadableShapes(): iterable + { + yield 'unsealed' => ['unsealed', 'array{a: int, ...}']; + yield 'keyed list' => ['keyedList', 'list{a: int}']; + yield 'double-quoted key' => ['doubleQuotedKey', 'array{"a": int}']; + yield 'unreadable value type' => ['unreadableValue', 'array{a: float<0.0, 1.0>}']; + } + + /** + * @return list + */ + private function draws(string $method, string $parameter): array + { + $arbitrary = $this->generator($method, $parameter); + $random = new Random(7); + $values = []; + + for ($i = 0; $i < self::DRAWS; ++$i) { + $values[] = $arbitrary->generate($random)->value; + } + + return $values; + } + + private function generator(string $method, string $parameter): ArbitraryInterface + { + return Gen::forParameters(new \ReflectionMethod(RefinedTypeMethods::class, $method))[$parameter]; + } +} diff --git a/tests/Support/Fixtures/GenerateFixtures.php b/tests/Support/Fixtures/GenerateFixtures.php new file mode 100644 index 0000000..2581f85 --- /dev/null +++ b/tests/Support/Fixtures/GenerateFixtures.php @@ -0,0 +1,128 @@ + + */ + public static function evens(): ArbitraryInterface + { + return Gen::map(Gen::intBetween(0, 50), static fn(int $half): int => $half * 2); + } + + /** + * @return ArbitraryInterface + */ + public function notStatic(): ArbitraryInterface + { + return Gen::constant(1); + } + + public static function notAnArbitrary(): int + { + return 1; + } + + /** + * @return ArbitraryInterface + */ + public static function throws(): ArbitraryInterface + { + throw new \LogicException('factory failed'); + } + + public function onEveryParameter( + #[Generate(new IntArbitrary(5, 5))] + int $fixed, + #[Generate('evens')] + int $even, + ): void {} + + public function onSomeParameters(#[Generate(new IntArbitrary(1, 1))] int $attributed, int $plain): void {} + + /** + * @param float<0.0, 1.0> $ratio + */ + public function overAnUnreadableDocblock(#[Generate(new FloatArbitrary(0.0, 1.0))] float $ratio): void {} + + /** + * @param int<100, 200> $number + */ + public function overAReadableDocblock(#[Generate(new IntArbitrary(1, 1))] int $number): void {} + + public function onANullable(#[Generate(new IntArbitrary(3, 3))] ?int $number): void {} + + public function arrayReference(#[Generate([GeneratorFactories::class, 'odd'])] int $number): void {} + + public function stringReference(#[Generate('Rasuvaeff\PropertyTesting\Tests\Support\Fixtures\GeneratorFactories::odd')] int $number): void {} + + public function invokable(#[Generate(new InvokableFactory())] int $number): void {} + + public function onAVariadic(#[Generate(new IntArbitrary(7, 7))] int ...$numbers): void {} + + public function invalidArguments(#[Generate(new IntArbitrary(10, 1))] int $number): void {} + + public function repeated(#[Generate(new IntArbitrary(1, 1))] #[Generate(new IntArbitrary(2, 2))] int $number): void {} + + public function nonStaticFactory(#[Generate('notStatic')] int $number): void {} + + public function notACallableArray(#[Generate([GeneratorFactories::class, 'missing'])] int $number): void {} + + public function notAFactory(#[Generate('noSuchFactory')] int $number): void {} + + public function returnsNoArbitrary(#[Generate('notAnArbitrary')] int $number): void {} + + public function factoryThrows(#[Generate('throws')] int $number): void {} +} + +/** + * Factories referenced from another class. + */ +final class GeneratorFactories +{ + /** + * @return ArbitraryInterface + */ + public static function odd(): ArbitraryInterface + { + return Gen::map(Gen::intBetween(0, 50), static fn(int $half): int => $half * 2 + 1); + } +} + +final class InvokableFactory +{ + /** + * @return ArbitraryInterface + */ + public function __invoke(): ArbitraryInterface + { + return Gen::constant(9); + } +} + +/** + * A constructor read by {@see Gen::forClass()}: the attribute reaches a VO's + * parameters through the same resolution. + */ +final readonly class AttributedValue +{ + public function __construct( + #[Generate(new IntArbitrary(42, 42))] + public int $answer, + public bool $flag, + ) {} +} diff --git a/tests/Support/Fixtures/RefinedTypeFixtures.php b/tests/Support/Fixtures/RefinedTypeFixtures.php new file mode 100644 index 0000000..1bee5c3 --- /dev/null +++ b/tests/Support/Fixtures/RefinedTypeFixtures.php @@ -0,0 +1,65 @@ +} $keyed + * @param array{int<0, 9>, 'a'|'b'} $positional + * @param list{bool, int<1, 1>} $list + * @param array{} $empty + * @param array{a?: ?int} $optionalNullable + * @param array, b: int<0, 9>}> $nested + * @param array{a: int<0, 9>}|null $shapeOrNull + * @param array{ + * a: int<0, 9>, + * b: bool, + * } $multiline + */ + public function shapes( + array $keyed, + array $positional, + array $list, + array $empty, + array $optionalNullable, + array $nested, + ?array $shapeOrNull, + array $multiline, + ): void {} + + /** + * @param array{a: int, ...} $shape + */ + public function unsealed(array $shape): void {} + + /** + * @param list{a: int} $shape + */ + public function keyedList(array $shape): void {} + + /** + * @param array{"a": int} $shape + */ + public function doubleQuotedKey(array $shape): void {} + + /** + * @param array{a: float<0.0, 1.0>} $shape + */ + public function unreadableValue(array $shape): void {} +} From ce7a99ca5cfdcf8c221a564de4114a5e517edbd0 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Fri, 2 Oct 2026 22:19:38 +0300 Subject: [PATCH 2/4] Pin the adapter contract job and the docs workspace at core 1.2.0; document #[Generate] in the guide --- .github/workflows/build.yml | 2 +- AGENTS.md | 6 +- docs/.api-workspace/composer.json | 2 +- docs/src/guide/generators/from-a-class.md | 71 ++++++++++++++++++++--- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e622916..c9a9bcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -315,7 +315,7 @@ jobs: # they no longer accept, this job is meant to go red until they do. working-directory: adapter run: | - composer config repositories.core '{"type":"path","url":"../core","options":{"versions":{"rasuvaeff/property-testing-core":"1.0.0"}}}' + composer config repositories.core '{"type":"path","url":"../core","options":{"versions":{"rasuvaeff/property-testing-core":"1.2.0"}}}' composer update --no-interaction --no-progress - name: Verify core resolved to the path repository diff --git a/AGENTS.md b/AGENTS.md index 2d30721..8b37a85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,7 +220,9 @@ make release-check The job asserts the path install took effect (`composer show` prints a `path :` line) precisely so it cannot silently pass against the released core from Packagist. Its path-repository `versions` override is pinned at - `1.0.0` because both adapters accept `^1.0`: when core moves to a version + `1.2.0`, the version that brings `Gen::forParameters(derive:)` both adapters + call; it was raised ahead of the adapters, whose `^1.0` already accepts it, + so the job stays green on both sides of their move to `^1.2`. When core moves to a version they do not accept, this job is *supposed* to fail until the adapters are updated — do not paper over it by widening the override. Keeping it in step is part of a core release, not an afterthought: the pin sat at `0.7.0` while @@ -246,7 +248,7 @@ make release-check set -e cleanup() { composer config --unset repositories.core; rm -f composer.lock; } trap cleanup EXIT - composer config repositories.core "{\"type\":\"path\",\"url\":\"../property-testing-core\",\"options\":{\"versions\":{\"rasuvaeff/property-testing-core\":\"1.0.0\"}}}" + composer config repositories.core "{\"type\":\"path\",\"url\":\"../property-testing-core\",\"options\":{\"versions\":{\"rasuvaeff/property-testing-core\":\"1.2.0\"}}}" composer update composer test ' || failed="$failed $adapter" diff --git a/docs/.api-workspace/composer.json b/docs/.api-workspace/composer.json index 8df6761..6fbc1bf 100644 --- a/docs/.api-workspace/composer.json +++ b/docs/.api-workspace/composer.json @@ -14,7 +14,7 @@ "url": "../..", "options": { "versions": { - "rasuvaeff/property-testing-core": "1.0.0" + "rasuvaeff/property-testing-core": "1.2.0" } } } diff --git a/docs/src/guide/generators/from-a-class.md b/docs/src/guide/generators/from-a-class.md index 29d45d4..0888256 100644 --- a/docs/src/guide/generators/from-a-class.md +++ b/docs/src/guide/generators/from-a-class.md @@ -67,8 +67,11 @@ generator that matches the domain and a demo. ## What it reads, in order -Per parameter: an **override**, then the **`@param` docblock**, then the -**native type**. The docblock wins over the native type because it says more. +Per parameter: an **override**, then a [**`#[Generate]` +attribute**](#a-generator-on-the-parameter), then the **`@param` docblock**, +then the **native type**. The docblock wins over the native type because it +says more; a docblock written across several lines is read like one on a +single line. | Written | Generated | |---|---| @@ -76,8 +79,13 @@ Per parameter: an **override**, then the **`@param` docblock**, then the | `int<0, 100>`, `int`, `int<0, max>` | `Gen::intBetween()` with those bounds | | `positive-int`, `negative-int`, `non-negative-int`, `non-positive-int` | the matching range | | `non-empty-string` | `Gen::stringOf(1, 100)` | +| `non-falsy-string`, `truthy-string` | non-empty and never `"0"` | +| `lowercase-string`, `non-empty-lowercase-string` | strings `mb_strtolower()` leaves unchanged | +| `numeric-string` | strings `is_numeric()` accepts, weighted towards signs, exponents, bare dots and surrounding whitespace | | `list`, `non-empty-list`, `T[]` | `Gen::arrayOf()` | | `array`, `non-empty-array` | `Gen::dictOf()` | +| `array{id: int, name?: string}`, `array{'quoted key': T, 3: U}` | a shape; an optional key is sometimes absent and shrinks towards absent — absent is not `null` | +| `array{int, string}`, `list{int, string}`, `array{}` | positional shapes and the empty array | | `'draft'\|'published'`, `1\|2\|3` | `Gen::elements()` — a domain spelled out in the type | | `A\|B` | `Gen::frequency()` over both | | `?T` | `Gen::nullable()` | @@ -85,13 +93,13 @@ Per parameter: an **override**, then the **`@param` docblock**, then the | `DateTimeImmutable` | `Gen::datetime()` | | another class | followed recursively, to `maxDepth` | -Anything else — a bare `array`, `mixed`, `callable`, an intersection, a -variadic constructor — is an **exception naming the parameter**, never a -widened guess: +Anything else — a bare `array`, `mixed`, `callable`, an intersection, an +unsealed shape (`array{a: int, ...}`), a variadic constructor — is an +**exception naming the parameter**, never a widened guess: ``` Cannot generate …\Unreadable: parameter $anything is typed array, which this -cannot read; pass an override +cannot read; pass an override or #[Generate] ``` A class it cannot instantiate — a value object with a private constructor and @@ -106,6 +114,40 @@ Cannot generate …\Duration: it is not instantiable (reached through That refusal is the design. A guessed generator does not fail here; it fails later, in somebody's test, as a value their code was never meant to see. +## A generator on the parameter + +When no type says what a parameter needs, the parameter can carry its +generator itself: + +```php +use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary; +use Rasuvaeff\PropertyTesting\Generate; + +public function __construct( + #[Generate(new IntArbitrary(0, 10_000))] + public int $base, + #[Generate([Generators::class, 'email'])] + public string $owner, +) {} +``` + +The argument is either an arbitrary built with `new` or a reference to a static +factory that returns one — a method name of the declaring class, +`'Class::method'`, `[Class::class, 'method']`, an invokable object. An +attribute argument is a constant expression: `Gen::*` calls cannot appear in +it, and on PHP 8.3 and 8.4 neither can a closure, so `Gen::map()`, +`Gen::email()`, `Gen::regex()` and the rest of the closure-built generators go +through a factory reference. + +The attribute is exact. It wins over a docblock it disagrees with, stands in +for one that cannot be read, and a nullable parameter is not made nullable on +top of it. Anything wrong with it — constructor arguments the arbitrary +rejects, a method that is not static, a factory returning something other +than an arbitrary — is an exception naming the parameter. + +`Generate` belongs to test code: on a production constructor it would make +this package a runtime dependency. + ## A validating constructor Constructors in this family reject bad input, and by default a rejection @@ -136,7 +178,7 @@ descent rather than thrown from it. ## The same rules for a whole property `Gen::forParameters()` applies the same resolution — an override, -the `@param` psalm type, then the native type, with the same supported subset +`#[Generate]`, the `@param` psalm type, then the native type, with the same supported subset and the same refusals — to the parameters of any function, method, or closure, and returns the map a property needs: `array` by parameter name, in signature order. @@ -191,6 +233,21 @@ that is the one they meant. And under auto, a provider key that is not a parameter of the property is an error — merge semantics would otherwise silently replace a mistyped entry with a signature-derived generator. +Without auto, the adapters still read `#[Generate]`: through +`Gen::forParameters(derive: false)`, which takes only overrides and attributes +and refuses a parameter with neither. A property whose parameters all carry +the attribute needs no provider and no `auto`: + +```php +#[Property] +public function delayStaysWithinCap( + #[Generate(new IntArbitrary(1, 300))] + int $base, + #[Generate(new IntArbitrary(1, 86_400))] + int $cap, +): void { /* … */ } +``` + There is no `skipInvalid` here: nothing is executed at derivation time, and a property body filters untrusted input through [`Assume`](/guide/controlling-runs/assume-vs-filter), as always. From 0372dbdc5805c10b1033e3653e867d15a433034d Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Fri, 2 Oct 2026 22:29:29 +0300 Subject: [PATCH 3/4] docs: keep the guide's prose within the Vale vocabulary --- docs/src/guide/generators/from-a-class.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/guide/generators/from-a-class.md b/docs/src/guide/generators/from-a-class.md index 0888256..30ed585 100644 --- a/docs/src/guide/generators/from-a-class.md +++ b/docs/src/guide/generators/from-a-class.md @@ -140,8 +140,8 @@ it, and on PHP 8.3 and 8.4 neither can a closure, so `Gen::map()`, through a factory reference. The attribute is exact. It wins over a docblock it disagrees with, stands in -for one that cannot be read, and a nullable parameter is not made nullable on -top of it. Anything wrong with it — constructor arguments the arbitrary +for one that cannot be read, and on a `?T` parameter it is not wrapped in +`Gen::nullable()` on top. Anything wrong with it — constructor arguments the arbitrary rejects, a method that is not static, a factory returning something other than an arbitrary — is an exception naming the parameter. From b0a095a333131e6cbcf39c71ee2bbde86cc56c75 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Fri, 2 Oct 2026 22:37:41 +0300 Subject: [PATCH 4/4] Release 1.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d3f48..05ff182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.2.0 — 2026-10-02 - **Added:** `#[Generate]` — a parameter attribute holding its generator, read by `Gen::forParameters()` and `Gen::forClass()` after an override and before the