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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ parity is golden rule 3.
| `PROPERTY_RUNS` | Always (`false`/`''` = unset) | `/^\d+\z/`, `>= 1` | Overrides every property's run count, including `runs()` | `InvalidArgumentException` |
| `PROPERTY_SEED` | Only when `seed()` was not called (explicit seed wins) | `/^-?\d+\z/` | Seeds every unseeded property; unset means a random seed per property | `InvalidArgumentException` |
| `PROPERTY_VERBOSE` | Always | Any value except `''` and `'0'` enables | Attaches `VerboseListener`: every run's arguments/draws and each accepted shrink step | n/a (falsy values disable) |
| `PROPERTY_DB` | Always (`false`/`''` = off, nothing written) | Directory path (created on demand) | Regression corpus via `FilesystemCorpus::fromEnv()`: record on falsification, replay before the random phase, prune on green replay. An explicit `seed()` disables replay for that property | n/a |
| `PROPERTY_DB` | Always (`false`/`''` = off, nothing written) | Directory path (created on demand) **or** `redis://host[:port][/key-prefix]` | Regression corpus via `CorpusFromEnv::resolve()`: a path builds a `FilesystemCorpus`, a DSN a `RedisCorpus` (ext-redis preferred, else predis). An explicit `seed()` disables replay for that property | `InvalidArgumentException` — an unusable DSN, or no Redis client installed. Never a silent fall back to the filesystem |
| `PROPERTY_PHASES` | Always (`false`/`''` = unset) | Comma-separated phase names, case-insensitive: `examples`, `corpus`, `random`, `shrink` | Stages of every run, in run order — **overrides** `phases()` | `InvalidArgumentException` naming the accepted values |
| `PROPERTY_DERANDOMIZE` | Always | Any value except `''` and `'0'` enables | Derives every unset seed from the property id — **overrides** `derandomize()` | n/a (falsy values disable) |
| `PROPERTY_PATH` | Only when `path()` was not called (explicit path wins) | A recorded `CounterExample::$path` | Replays that shrink descent instead of searching for it; needs the seed of the run that produced it | engine rejects a path that would be a silent no-op |
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- `PROPERTY_DB` now also takes a `redis://host[:port][/key-prefix]` DSN, which
builds core 0.3's `RedisCorpus`. Until now that class existed and no suite
could reach it: the engine reads no environment by design, and this adapter
hardcoded the filesystem corpus. A directory keeps meaning exactly what it
meant. `ext-redis` is preferred when loaded, `predis/predis` otherwise, and
neither installed is an error rather than a silent fall back to the
filesystem. Same variable, same messages as the Testo adapter.

## 0.3.0 — 2026-08-15

- Added `PropertyCheck::edgeCases()` and `PROPERTY_EDGE_CASES` (`mixin` or
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,25 @@ Byte-for-byte parity with the Testo adapter — one contract across adapters:
| `PROPERTY_RUNS` | Positive integer that overrides every property's run count (dial runs up in CI) |
| `PROPERTY_SEED` | Integer seed for any property without an explicit `seed()` (replay a whole suite). An explicit `seed()` still wins |
| `PROPERTY_VERBOSE` | Any value except `''`/`'0'` logs every run's generated arguments and each accepted shrink step |
| `PROPERTY_DB` | Directory path enabling the regression corpus. Unset means off, nothing is written |
| `PROPERTY_DB` | Directory path enabling the regression corpus, or a `redis://host[:port][/key-prefix]` DSN for a corpus shared between CI and developers. Unset means off, nothing is written |
| `PROPERTY_PHASES` | Comma-separated stage list (`examples,corpus,random,shrink`, case-insensitive) that overrides `phases()` — an unknown name throws rather than skipping a stage. `examples,corpus` is the fast pull-request gate |
| `PROPERTY_DERANDOMIZE` | Any value except `''`/`'0'` derives every unset seed from the property id, making a whole suite reproducible without editing it |
| `PROPERTY_PATH` | A recorded shrink descent (`CounterExample::$path`) replayed instead of searched for. Needs the seed that produced it; an explicit `path()` wins |
| `PROPERTY_EDGE_CASES` | `mixin` or `none` (case-insensitive) — the numeric boundary bias for the whole suite, overriding `edgeCases()`. An unknown value throws |

`PROPERTY_DB` takes either a directory or a Redis DSN:

```bash
PROPERTY_DB=/tmp/corpus vendor/bin/phpunit # one machine
PROPERTY_DB=redis://127.0.0.1:6379 vendor/bin/phpunit # shared
PROPERTY_DB=redis://redis:6379/suite-a: vendor/bin/phpunit # shared server, own prefix
```

A directory remembers a counterexample for whoever owns it — in CI, a machine
deleted when the job ends. The Redis form is the same corpus, in the same
document, shared. It needs `ext-redis` or `predis/predis`; neither installed is
an error rather than a silent fall back to the filesystem.

The corpus format is exactly the one `rasuvaeff/property-testing` 2.8 wrote —
a corpus recorded under Testo (or under 2.x) replays here and vice versa. On
falsification the minimal input is recorded; the next run replays recorded
Expand Down
15 changes: 14 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,25 @@ $this->forAll(['values' => Gen::arrayOf(Gen::int())])
| `PROPERTY_RUNS` | Положительное целое, переопределяет число прогонов каждой property (поднять runs в CI) |
| `PROPERTY_SEED` | Целочисленный seed для property без явного `seed()` (реплей всего suite). Явный `seed()` важнее |
| `PROPERTY_VERBOSE` | Любое значение кроме `''`/`'0'` логирует аргументы каждого прогона и каждый принятый shrink-шаг |
| `PROPERTY_DB` | Путь к каталогу, включающий regression corpus. Не заданавыключено, ничего не пишется |
| `PROPERTY_DB` | Путь к каталогу, включающий регрессионный корпус, либо DSN `redis://host[:port][/key-prefix]` для корпуса, общего между CI и разработчиками. Не заданвыключен, ничего не пишется |
| `PROPERTY_PHASES` | Список стадий через запятую (`examples,corpus,random,shrink`, регистр не важен), перекрывающий `phases()`; неизвестное имя — исключение, а не пропуск стадии. `examples,corpus` — быстрый гейт для pull request |
| `PROPERTY_DERANDOMIZE` | Любое значение, кроме `''`/`'0'`, выводит каждый незаданный seed из id property: весь сьют становится воспроизводимым без правки кода |
| `PROPERTY_PATH` | Записанный спуск shrink (`CounterExample::$path`) воспроизводится вместо повторного поиска. Нужен seed того прогона; явный `path()` побеждает |
| `PROPERTY_EDGE_CASES` | `mixin` или `none` (регистр не важен) — граничное смещение для всего сьюта, перекрывает `edgeCases()`. Неизвестное значение — исключение |

`PROPERTY_DB` принимает либо каталог, либо Redis-DSN:

```bash
PROPERTY_DB=/tmp/corpus vendor/bin/phpunit # одна машина
PROPERTY_DB=redis://127.0.0.1:6379 vendor/bin/phpunit # общий
PROPERTY_DB=redis://redis:6379/suite-a: vendor/bin/phpunit # общий сервер, свой префикс
```

Каталог помнит контрпример для того, кто им владеет, — в CI это машина,
которую удаляют вместе с job'ом. Redis-форма — тот же корпус в том же
документе, но общий. Нужен `ext-redis` или `predis/predis`; отсутствие обоих —
ошибка, а не тихий откат на файловую систему.

Формат корпуса — ровно тот, что писал `rasuvaeff/property-testing` 2.8:
корпус, записанный под Testo (или под 2.x), реплеится здесь, и наоборот. При
falsification записывается минимальный вход; следующий прогон реплеит
Expand Down
19 changes: 19 additions & 0 deletions composer-require-checker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"symbol-whitelist": [
"Predis\\Client",
"Redis"
],
"php-core-extensions": [
"Core",
"date",
"json",
"hash",
"pcre",
"Phar",
"Reflection",
"SPL",
"random",
"standard"
],
"scan-files": []
}
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@
"friendsofphp/php-cs-fixer": "^3.95",
"infection/infection": "^0.33 || ^0.34",
"maglnet/composer-require-checker": "^4.17",
"predis/predis": "^2.2 || ^3.0",
"rasuvaeff/rector-named-literals": "^1.0",
"rector/rector": "^2.4",
"roave/backward-compatibility-check": "^8.0",
"vimeo/psalm": "^6.16"
},
"suggest": {
"ext-redis": "Required for PROPERTY_DB=redis://… — a regression corpus shared between CI and developers",
"predis/predis": "Required for PROPERTY_DB=redis://… when ext-redis is unavailable"
},
"autoload": {
"psr-4": {
"Rasuvaeff\\PropertyTesting\\": "src/"
Expand Down Expand Up @@ -79,7 +84,7 @@
"@rector",
"@bc-check"
],
"require-checker": "composer-require-checker check composer.json",
"require-checker": "composer-require-checker check composer.json --config-file=composer-require-checker.json",
"test": "phpunit",
"test:coverage": "phpunit --coverage-clover=build/coverage.xml",
"test:coverage:ci": "phpunit --coverage-clover=build/coverage.xml"
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ final class SortPropertyTest extends TestCase
| `PROPERTY_RUNS` | `/^\d+\z/`, >= 1 | Overrides every property's run count; invalid value throws `InvalidArgumentException` |
| `PROPERTY_SEED` | `/^-?\d+\z/` | Seeds properties without an explicit `seed()`; `seed()` wins; invalid value throws |
| `PROPERTY_VERBOSE` | anything but `''`/`'0'` | Trace: each run's arguments/draws, each accepted shrink step |
| `PROPERTY_DB` | directory path | Regression corpus: record on falsification, replay first on the next run, prune on green; format = property-testing 2.8 |
| `PROPERTY_DB` | directory path OR `redis://host[:port][/key-prefix]` | Regression corpus: record on falsification, replay first on the next run, prune on green. A DSN builds a RedisCorpus (ext-redis preferred, else predis; neither installed THROWS) — same document as the directory form, shared between CI and developers |
| `PROPERTY_PHASES` | `examples,corpus,random,shrink` (comma-separated, case-insensitive) | Stages to run; OVERRIDES `phases()`; unknown name throws |
| `PROPERTY_DERANDOMIZE` | anything but `''`/`'0'` | Unset seeds derived from the property id; `seed()`/`PROPERTY_SEED` still win |
| `PROPERTY_PATH` | recorded `CounterExample::$path` | Replays that shrink descent; needs the seed of that run; explicit `path()` wins |
Expand Down
5 changes: 5 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@
</errorLevel>
</InternalMethod>
</issueHandlers>
<!-- CorpusFromEnv types against \Redis; the bundled stub lets psalm analyze
it without ext-redis being installed. -->
<enableExtensions>
<extension name="redis"/>
</enableExtensions>
</psalm>
86 changes: 86 additions & 0 deletions src/PhpUnit/CorpusFromEnv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\PropertyTesting\PhpUnit;

use Rasuvaeff\PropertyTesting\Runner\Corpus;
use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus;
use Rasuvaeff\PropertyTesting\Runner\Redis\CorpusClient;
use Rasuvaeff\PropertyTesting\Runner\Redis\PredisCorpusClient;
use Rasuvaeff\PropertyTesting\Runner\RedisCorpus;

/**
* `PROPERTY_DB`, as a {@see Corpus}.
*
* The engine deliberately reads no environment — resolving where a corpus
* lives is the adapter's job, which is why this class exists here rather than
* in core. A directory keeps meaning what it always meant; a `redis://` DSN
* means the corpus is shared, so a falsification found on a laptop replays in
* CI and one found in CI replays on the next laptop.
*
* ```bash
* PROPERTY_DB=/tmp/corpus vendor/bin/phpunit
* PROPERTY_DB=redis://127.0.0.1:6379 vendor/bin/phpunit
* PROPERTY_DB=redis://redis:6379/my-suite: vendor/bin/phpunit
* ```
*
* @internal Driven by the fluent property check.
*/
final class CorpusFromEnv
{
private const string SCHEME = 'redis://';

private function __construct()
{
// Static helper; not instantiable.
}

/**
* The corpus `PROPERTY_DB` asks for, or null when it is unset (storage
* off, nothing written).
*/
public static function resolve(): ?Corpus
{
$dsn = getenv('PROPERTY_DB');

if ($dsn === false || $dsn === '') {
return null;
}

if (!str_starts_with($dsn, self::SCHEME)) {
return new FilesystemCorpus($dsn);
}

$parsed = RedisDsn::parse($dsn);

return new RedisCorpus(self::client($parsed, $dsn), $parsed->prefix);
}

/**
* A client for the DSN, preferring `ext-redis` when it is loaded because
* it needs no autoloaded dependency at all.
*
* Neither available is a configuration error, not a silent fall back to
* the filesystem: a suite told to share its corpus and quietly writing to
* a directory nobody reads is worse than one that stops.
*/
private static function client(RedisDsn $dsn, string $raw): CorpusClient
{
if (extension_loaded('redis')) {
// Lazily: resolving PROPERTY_DB must not open a socket, or a suite
// that names a corpus it never touches fails at startup.
return new LazyPhpRedisCorpusClient($dsn);
}

if (class_exists(\Predis\Client::class)) {
return new PredisCorpusClient(new \Predis\Client($dsn->toPredisParameters()));
}

throw new \InvalidArgumentException(sprintf(
'PROPERTY_DB="%s" needs a Redis client: install ext-redis or require predis/predis',
$raw,
));
}

}
56 changes: 56 additions & 0 deletions src/PhpUnit/LazyPhpRedisCorpusClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\PropertyTesting\PhpUnit;

use Rasuvaeff\PropertyTesting\Runner\Redis\CorpusClient;
use Rasuvaeff\PropertyTesting\Runner\Redis\PhpRedisCorpusClient;

/**
* `ext-redis`, connected on first use rather than on construction.
*
* Core's {@see PhpRedisCorpusClient} takes an already-connected `\Redis`,
* which is the right contract for a class that does not own the connection —
* but resolving `PROPERTY_DB` must not open a socket. A suite that names a
* corpus it never touches (every property pinned by an explicit seed, say)
* would otherwise fail at startup against a server it never needed, and CI
* proved it: the eager version was red everywhere the extension was installed
* and Redis was not running.
*
* predis is lazy by construction, so only this side needed the wrapper.
*
* @internal Driven by {@see CorpusFromEnv}.
*/
final class LazyPhpRedisCorpusClient implements CorpusClient
{
private ?PhpRedisCorpusClient $client = null;

public function __construct(
private readonly RedisDsn $dsn,
) {}

#[\Override]
public function get(string $key): ?string
{
return $this->client()->get($key);
}

#[\Override]
public function compareAndSet(string $key, ?string $expected, ?string $document): bool
{
return $this->client()->compareAndSet($key, $expected, $document);
}

private function client(): PhpRedisCorpusClient
{
if ($this->client instanceof PhpRedisCorpusClient) {
return $this->client;
}

$redis = new \Redis();
$redis->connect($this->dsn->host, $this->dsn->port);

return $this->client = new PhpRedisCorpusClient($redis);
}
}
3 changes: 1 addition & 2 deletions src/PhpUnit/PropertyCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use Rasuvaeff\PropertyTesting\Runner\CallableTrialExecutor;
use Rasuvaeff\PropertyTesting\Runner\CoverageFailed;
use Rasuvaeff\PropertyTesting\Runner\EdgeCases;
use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus;
use Rasuvaeff\PropertyTesting\Runner\GaveUp;
use Rasuvaeff\PropertyTesting\Runner\Passed;
use Rasuvaeff\PropertyTesting\Runner\Phase;
Expand Down Expand Up @@ -283,7 +282,7 @@
*/
public function listeners(PropertyListener ...$listeners): self
{
$this->listeners = array_values($listeners);

Check warning on line 285 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "UnwrapArrayValues": @@ @@ */ public function listeners(PropertyListener ...$listeners): self { - $this->listeners = array_values($listeners); + $this->listeners = $listeners; return $this; }

return $this;
}
Expand Down Expand Up @@ -354,7 +353,7 @@
$definition,
new CallableTrialExecutor($property),
$listeners,
FilesystemCorpus::fromEnv(),
CorpusFromEnv::resolve(),
);

$statistics = match (true) {
Expand Down Expand Up @@ -390,7 +389,7 @@
$classifications = $statistics->classifications;
$checks = $statistics->checks;

if ($classifications === [] || $checks <= 0) {

Check warning on line 392 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LessThanOrEqualTo": @@ @@ $classifications = $statistics->classifications; $checks = $statistics->checks; - if ($classifications === [] || $checks <= 0) { + if ($classifications === [] || $checks < 0) { return; }
return;
}

Expand All @@ -401,7 +400,7 @@
$parts[] = sprintf(
'%s %d%% (%d/%d)',
$label,
(int) round(((float) $count / (float) $checks) * 100.0),

Check warning on line 403 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ $parts[] = sprintf( '%s %d%% (%d/%d)', $label, - (int) round(((float) $count / (float) $checks) * 100.0), + (int) round(((float) $count / $checks) * 100.0), $count, $checks, );

Check warning on line 403 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ $parts[] = sprintf( '%s %d%% (%d/%d)', $label, - (int) round(((float) $count / (float) $checks) * 100.0), + (int) round(($count / (float) $checks) * 100.0), $count, $checks, );
$count,
$checks,
);
Expand Down Expand Up @@ -441,7 +440,7 @@
$this->name,
$skips,
$attempts,
(int) round(((float) $skips / (float) $attempts) * 100.0),

Check warning on line 443 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ $this->name, $skips, $attempts, - (int) round(((float) $skips / (float) $attempts) * 100.0), + (int) round(((float) $skips / $attempts) * 100.0), ) . "\n"); }

Check warning on line 443 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ $this->name, $skips, $attempts, - (int) round(((float) $skips / (float) $attempts) * 100.0), + (int) round(($skips / (float) $attempts) * 100.0), ) . "\n"); }
) . "\n");
}

Expand Down Expand Up @@ -548,7 +547,7 @@
return null;
}

if (preg_match('/^\d+\z/', $env) !== 1 || (int) $env < 1) {

Check warning on line 550 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastInt": @@ @@ return null; } - if (preg_match('/^\d+\z/', $env) !== 1 || (int) $env < 1) { + if (preg_match('/^\d+\z/', $env) !== 1 || $env < 1) { throw new \InvalidArgumentException(sprintf('PROPERTY_RUNS must be a positive integer, got "%s"', $env)); }
throw new \InvalidArgumentException(sprintf('PROPERTY_RUNS must be a positive integer, got "%s"', $env));
}

Expand Down
72 changes: 72 additions & 0 deletions src/PhpUnit/RedisDsn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\PropertyTesting\PhpUnit;

/**
* `redis://host[:port][/key-prefix]`, taken apart.
*
* Parsing lives in its own type because it is the part with answers worth
* asserting — a default port and a default prefix are decisions, and a
* resolver that also builds clients hides them behind a connection.
*
* @internal Driven by {@see CorpusFromEnv}.
*/
final readonly class RedisDsn
{
public const int DEFAULT_PORT = 6379;

/** The engine's own default, so a DSN without a path behaves like the plain constructor. */
public const string DEFAULT_PREFIX = 'property-testing:corpus:';

/**
* @param non-empty-string $host
* @param non-empty-string $prefix
*/
public function __construct(
public string $host,
public int $port,
public string $prefix,
) {}

/**
* The connection parameters predis takes.
*
* Here rather than at the call site so the shape is something a test can
* assert: an array literal built where the client is constructed can only
* be checked by connecting to a server.
*
* @return array{scheme: 'tcp', host: non-empty-string, port: int}
*/
public function toPredisParameters(): array
{
return ['scheme' => 'tcp', 'host' => $this->host, 'port' => $this->port];
}

/**
* @param string $dsn The value of `PROPERTY_DB`, already known to start with `redis://`.
*/
public static function parse(string $dsn): self
{
$parts = parse_url($dsn);
$host = is_array($parts) ? ($parts['host'] ?? null) : null;

if (!is_string($host) || $host === '') {
throw new \InvalidArgumentException(sprintf(
'PROPERTY_DB="%s" is not a usable Redis DSN; expected redis://host[:port][/key-prefix]',
$dsn,
));
}

$port = is_array($parts) ? ($parts['port'] ?? null) : null;
$path = is_array($parts) ? ($parts['path'] ?? null) : null;
$prefix = is_string($path) ? ltrim($path, '/') : '';

return new self(
host: $host,
port: is_int($port) ? $port : self::DEFAULT_PORT,
prefix: $prefix === '' ? self::DEFAULT_PREFIX : $prefix,
);
}
}
Loading
Loading