Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 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
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
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LineItem>`, `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<string, ArbitraryInterface>` 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<LineItem>`, `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<string, ArbitraryInterface>` 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
Expand Down
44 changes: 42 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LineItem>`, `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<string, ArbitraryInterface>` для параметров `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<LineItem>`, `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<string, ArbitraryInterface>` для параметров `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` для
Expand Down
2 changes: 1 addition & 1 deletion docs/.api-workspace/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"url": "../..",
"options": {
"versions": {
"rasuvaeff/property-testing-core": "1.0.0"
"rasuvaeff/property-testing-core": "1.2.0"
}
}
}
Expand Down
Loading
Loading