diff --git a/AGENTS.md b/AGENTS.md index 31e26be..c553e69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index b882eaf..4bbfaa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e545d74..76c1f57 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru.md b/README.ru.md index 1f6048b..3997ac6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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 записывается минимальный вход; следующий прогон реплеит diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..016dd1c --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,19 @@ +{ + "symbol-whitelist": [ + "Predis\\Client", + "Redis" + ], + "php-core-extensions": [ + "Core", + "date", + "json", + "hash", + "pcre", + "Phar", + "Reflection", + "SPL", + "random", + "standard" + ], + "scan-files": [] +} diff --git a/composer.json b/composer.json index 6fcd58e..1224a64 100644 --- a/composer.json +++ b/composer.json @@ -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/" @@ -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" diff --git a/llms.txt b/llms.txt index d31f152..9d303f2 100644 --- a/llms.txt +++ b/llms.txt @@ -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 | diff --git a/psalm.xml b/psalm.xml index e10ac79..45edfd9 100644 --- a/psalm.xml +++ b/psalm.xml @@ -41,4 +41,9 @@ + + + + diff --git a/src/PhpUnit/CorpusFromEnv.php b/src/PhpUnit/CorpusFromEnv.php new file mode 100644 index 0000000..2a94087 --- /dev/null +++ b/src/PhpUnit/CorpusFromEnv.php @@ -0,0 +1,86 @@ +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, + )); + } + +} diff --git a/src/PhpUnit/LazyPhpRedisCorpusClient.php b/src/PhpUnit/LazyPhpRedisCorpusClient.php new file mode 100644 index 0000000..25bcff7 --- /dev/null +++ b/src/PhpUnit/LazyPhpRedisCorpusClient.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/src/PhpUnit/PropertyCheck.php b/src/PhpUnit/PropertyCheck.php index 647e9d2..002caa4 100644 --- a/src/PhpUnit/PropertyCheck.php +++ b/src/PhpUnit/PropertyCheck.php @@ -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; @@ -354,7 +353,7 @@ public function check(\Closure $property): void $definition, new CallableTrialExecutor($property), $listeners, - FilesystemCorpus::fromEnv(), + CorpusFromEnv::resolve(), ); $statistics = match (true) { diff --git a/src/PhpUnit/RedisDsn.php b/src/PhpUnit/RedisDsn.php new file mode 100644 index 0000000..c6e5e48 --- /dev/null +++ b/src/PhpUnit/RedisDsn.php @@ -0,0 +1,72 @@ + '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, + ); + } +} diff --git a/tests/CorpusFromEnvTest.php b/tests/CorpusFromEnvTest.php new file mode 100644 index 0000000..aef8a49 --- /dev/null +++ b/tests/CorpusFromEnvTest.php @@ -0,0 +1,139 @@ +getValue($corpus); + + self::assertInstanceOf( + extension_loaded('redis') ? LazyPhpRedisCorpusClient::class : PredisCorpusClient::class, + $client, + ); + } finally { + $restore(); + } + } + + public function testARedisDsnIsASharedCorpus(): void + { + $restore = Env::set('PROPERTY_DB', 'redis://127.0.0.1:6379'); + + try { + self::assertInstanceOf(RedisCorpus::class, CorpusFromEnv::resolve()); + } finally { + $restore(); + } + } + + public function testTheDsnPathIsTheKeyPrefix(): void + { + // Two suites can share one server without sharing a corpus. What the + // prefix parses to is RedisDsn's business and is pinned there; this + // asserts only that the resolver hands it over. + $restore = Env::set('PROPERTY_DB', 'redis://127.0.0.1:6379/suite-a:'); + + try { + $corpus = CorpusFromEnv::resolve(); + self::assertInstanceOf(RedisCorpus::class, $corpus); + + $prefix = (new \ReflectionProperty($corpus, 'prefix'))->getValue($corpus); + self::assertSame('suite-a:', $prefix); + } finally { + $restore(); + } + } + + public function testAnUnusableDsnSurfacesAsAConfigurationError(): void + { + $restore = Env::set('PROPERTY_DB', 'redis://'); + + try { + CorpusFromEnv::resolve(); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('not a usable Redis DSN', $e->getMessage()); + } finally { + $restore(); + } + } +} diff --git a/tests/RedisDsnTest.php b/tests/RedisDsnTest.php new file mode 100644 index 0000000..7ca1e10 --- /dev/null +++ b/tests/RedisDsnTest.php @@ -0,0 +1,86 @@ +host); + self::assertSame($port, $parsed->port); + self::assertSame($prefix, $parsed->prefix); + } + + /** + * @return iterable + */ + public static function dsnProvider(): iterable + { + yield 'host only' => ['redis://127.0.0.1', '127.0.0.1', 6379, 'property-testing:corpus:']; + yield 'host and port' => ['redis://redis:6380', 'redis', 6380, 'property-testing:corpus:']; + yield 'prefix in the path' => ['redis://redis:6380/suite-a:', 'redis', 6380, 'suite-a:']; + yield 'prefix without a port' => ['redis://redis/suite-b:', 'redis', 6379, 'suite-b:']; + yield 'trailing slash is not a prefix' => ['redis://redis/', 'redis', 6379, 'property-testing:corpus:']; + yield 'nested path keeps its separators' => ['redis://redis/team/suite:', 'redis', 6379, 'team/suite:']; + } + + public function testTheConnectionParametersAreTheOnesPredisTakes(): void + { + // Asserted here because at the call site the same literal could only be + // checked by connecting to a server. + self::assertSame(['scheme' => 'tcp', 'host' => 'redis', 'port' => 6380], RedisDsn::parse('redis://redis:6380/suite:')->toPredisParameters()); + } + + public function testADsnWithoutAHostIsAConfigurationError(): void + { + try { + RedisDsn::parse('redis://'); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame('PROPERTY_DB="redis://" is not a usable Redis DSN; expected redis://host[:port][/key-prefix]', $e->getMessage()); + } + } + + #[DataProvider('malformedProvider')] + public function testAMalformedDsnIsAConfigurationError(string $dsn): void + { + // Each of these makes parse_url() return false outright. The message + // still has to quote the value, because it came from an environment + // variable somebody typed by hand. + try { + RedisDsn::parse($dsn); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString($dsn, $e->getMessage()); + self::assertStringContainsString('expected redis://host[:port][/key-prefix]', $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function malformedProvider(): iterable + { + yield 'no host at all' => ['redis://']; + yield 'port without a host' => ['redis://:6379']; + yield 'prefix without a host' => ['redis:///prefix:']; + yield 'port that is not a number' => ['redis://host:notaport']; + } +}