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'];
+ }
+}