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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ parity is golden rule 3.
| `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 |
| `PROPERTY_EDGE_CASES` | Always (`false`/`''` = unset) | `mixin` or `none`, case-insensitive, trimmed | Numeric boundary bias for every run — **overrides** `edgeCases()` | `InvalidArgumentException` naming the accepted values |

The split is deliberate and worth stating: **the environment dials the suite,
the code pins the property.** `PROPERTY_RUNS`, `PROPERTY_PHASES` and
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

- Added `PropertyCheck::edgeCases()` and `PROPERTY_EDGE_CASES` (`mixin` or
`none`), reaching core 0.3's switch for the numeric boundary bias. Turning it
off stops a property that cannot use `0`, `±1` or a range's ends from
spending one run in five on a value it discards. The variable overrides the
chain, like every other CI-facing knob, and an unknown value throws rather
than silently keeping the bias it was told to drop.
- **Requires `rasuvaeff/property-testing-core` `^0.3`.**

## 0.2.0 — 2026-08-14

- Added the 0.2 run knobs, as fluent setters and as environment variables:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Reproduce the exact run by pinning the reported seed: `->seed(7382910)`.
| `phases(array)` | Stages to perform (`Phase::Examples`, `Corpus`, `Random`, `Shrink`) — a subset trades coverage for time on purpose |
| `derandomize(bool)` | Derives an unset seed from the property id instead of drawing one; an explicit `seed()` still wins |
| `path(string)` | Replays a recorded shrink descent instead of searching for it; needs the seed that produced it |
| `edgeCases(EdgeCases)` | `None` turns off the numeric boundary bias — for a property the edges only cost runs |
| `output($stdout, $stderr)` | Redirects the distribution report, discard warning and verbose trace (used by this package's own tests) |

### Naming a property (`id()`)
Expand Down Expand Up @@ -162,6 +163,7 @@ Byte-for-byte parity with the Testo adapter — one contract across adapters:
| `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 |

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
Expand Down
2 changes: 2 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Property falsified after 12 successful run(s); seed=7382910
| `phases(array)` | Какие стадии выполнять (`Phase::Examples`, `Corpus`, `Random`, `Shrink`); подмножество осознанно обменивает покрытие на время прогона |
| `derandomize(bool)` | Выводит незаданный seed из id property вместо случайного; явный `seed()` всё равно побеждает |
| `path(string)` | Воспроизводит записанный спуск shrink вместо повторного поиска; нужен seed того прогона |
| `edgeCases(EdgeCases)` | `None` выключает граничное смещение числовых генераторов — для property, которой края стоят только прогонов |
| `output($stdout, $stderr)` | Перенаправляет отчёт распределения, предупреждение о discard'ах и verbose-трассу (используется тестами самого пакета) |

### Имя property (`id()`)
Expand Down Expand Up @@ -162,6 +163,7 @@ $this->forAll(['values' => Gen::arrayOf(Gen::int())])
| `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()`. Неизвестное значение — исключение |

Формат корпуса — ровно тот, что писал `rasuvaeff/property-testing` 2.8:
корпус, записанный под Testo (или под 2.x), реплеится здесь, и наоборот. При
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"require": {
"php": "8.3 - 8.5",
"phpunit/phpunit": "^11.5 || ^12.0 || ^13.0",
"rasuvaeff/property-testing-core": "^0.2"
"rasuvaeff/property-testing-core": "^0.3"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.51",
Expand Down
2 changes: 1 addition & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"text": "php://stderr",
"summary": "build/infection-summary.log"
},
// 146 of 154 mutants are killed; the remaining eight are equivalent
// 160 of 168 mutants are killed; the remaining eight are equivalent
// (report wording and ordering that no observable behaviour distinguishes),
// so 100 is unreachable without ignores — which are forbidden here.
"minMsi": 90,
Expand Down
2 changes: 2 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public function shrinkBudgetMs(int $ms): self; // wall-clock budget for th
public function phases(array $phases): self; // list<Phase>: Examples, Corpus, Random, Shrink
public function derandomize(bool $on = true): self; // unset seed derived from the property id
public function path(string $path): self; // replay a recorded shrink descent; needs its seed
public function edgeCases(EdgeCases $mode): self; // Mixin (default) | None = no numeric boundary bias
public function seed(int $seed): self; // pin the random phase; disables corpus replay
public function maxShrinks(int $maxShrinks): self; // cap accepted shrink steps; 0 = no shrinking
public function maxDiscards(int $maxDiscards): self; // discard budget; default runs * 10
Expand Down Expand Up @@ -112,6 +113,7 @@ final class SortPropertyTest extends TestCase
| `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 |
| `PROPERTY_EDGE_CASES` | `mixin` \| `none` (case-insensitive) | Numeric boundary bias; OVERRIDES `edgeCases()`; unknown value throws |

### Classification output

Expand Down
44 changes: 44 additions & 0 deletions src/PhpUnit/PropertyCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Rasuvaeff\PropertyTesting\PropertyListener;
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;
Expand Down Expand Up @@ -70,6 +71,7 @@
private ?ShrinkMode $shrink = null;
private ?int $shrinkBudgetMs = null;
private ?string $path = null;
private ?EdgeCases $edgeCases = null;
private ?bool $derandomize = null;

/** @var ?list<Phase> */
Expand Down Expand Up @@ -234,6 +236,22 @@
return $this;
}

/**
* Whether the numeric generators keep biasing toward their boundary values
* ({@see EdgeCases::Mixin}, the default) or generate uniformly
* ({@see EdgeCases::None}).
*
* Turn them off when the edges are what this property cannot use — a body
* discarding `0`, a range end that violates a precondition — so the
* discard budget stops paying for one run in five.
*/
public function edgeCases(EdgeCases $edgeCases): self
{
$this->edgeCases = $edgeCases;

return $this;
}

/**
* Replays the shrink descent of an earlier failure, as reported by
* `CounterExample::$path`, instead of searching for it again. It needs the
Expand Down Expand Up @@ -265,7 +283,7 @@
*/
public function listeners(PropertyListener ...$listeners): self
{
$this->listeners = array_values($listeners);

Check warning on line 286 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 @@ -318,6 +336,7 @@
phases: $this->envPhases() ?? $this->phases,
derandomize: $this->envDerandomize() ?? $this->derandomize ?? false,
path: $this->path ?? $this->envPath(),
edgeCases: $this->envEdgeCases() ?? $this->edgeCases ?? EdgeCases::Mixin,
),
examples: $this->examples,
replayRegressions: $this->seed === null,
Expand Down Expand Up @@ -371,7 +390,7 @@
$classifications = $statistics->classifications;
$checks = $statistics->checks;

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

Check warning on line 393 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 @@ -382,7 +401,7 @@
$parts[] = sprintf(
'%s %d%% (%d/%d)',
$label,
(int) round(((float) $count / (float) $checks) * 100.0),

Check warning on line 404 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 404 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 @@ -422,7 +441,7 @@
$this->name,
$skips,
$attempts,
(int) round(((float) $skips / (float) $attempts) * 100.0),

Check warning on line 444 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"); }

Check warning on line 444 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"); }
) . "\n");
}

Expand Down Expand Up @@ -463,6 +482,31 @@
return $phases;
}

/**
* `PROPERTY_EDGE_CASES` selects the numeric boundary bias for the whole
* suite: `mixin` (the default) or `none`, case-insensitive. It overrides
* the chain, like every other CI-facing knob, and an unknown value is an
* error rather than a silent fallback — a suite that quietly kept the bias
* it was told to drop would spend the discard budget it was trying to save.
*/
private function envEdgeCases(): ?EdgeCases
{
$env = getenv('PROPERTY_EDGE_CASES');

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

return match (strtolower(trim($env))) {
'mixin' => EdgeCases::Mixin,
'none' => EdgeCases::None,
default => throw new \InvalidArgumentException(sprintf(
'PROPERTY_EDGE_CASES must be one of mixin, none, got "%s"',
trim($env),
)),
};
}

/**
* `PROPERTY_DERANDOMIZE` (any value except '' and '0') derives every
* unset seed from the property id, which makes a whole suite reproducible
Expand Down Expand Up @@ -504,7 +548,7 @@
return null;
}

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

Check warning on line 551 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
79 changes: 79 additions & 0 deletions tests/EnvironmentParityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Rasuvaeff\PropertyTesting\PhpUnit\Tests\Support\RecordingListener;
use Rasuvaeff\PropertyTesting\PropertyViolationException;
use Rasuvaeff\PropertyTesting\RegressionViolationException;
use Rasuvaeff\PropertyTesting\Runner\EdgeCases;
use Rasuvaeff\PropertyTesting\Runner\Phase;

/**
Expand Down Expand Up @@ -473,6 +474,84 @@ public function testANamedPropertyWarnsAboutNothing(): void
self::assertSame('', (string) stream_get_contents($stderr));
}

public function testEdgeCasesOffKeepsBoundaryValuesOutOfTheRun(): void
{
// The knob's whole purpose: a property that cannot use the edges would
// otherwise throw away one run in five.
self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None)));
}

public function testByDefaultTheBoundaryValuesAreStillGenerated(): void
{
self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check));
}

public function testPropertyEdgeCasesOverridesTheChain(): void
{
putenv('PROPERTY_EDGE_CASES=none');

self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::Mixin)));
}

public function testPropertyEdgeCasesIgnoresSpacingAndCase(): void
{
putenv('PROPERTY_EDGE_CASES= NONE ');

self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check));
}

public function testPropertyEdgeCasesCanTurnThemBackOn(): void
{
putenv('PROPERTY_EDGE_CASES=mixin');

self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None)));
}

public function testPropertyEdgeCasesRejectsAnUnknownValue(): void
{
// Spaces on purpose: the message quotes the trimmed value, so a
// reader sees what was compared rather than what was typed.
putenv('PROPERTY_EDGE_CASES= sometimes ');

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('PROPERTY_EDGE_CASES must be one of mixin, none, got "sometimes"');

$this->forAll(['value' => Gen::intBetween(0, 10)])
->check(static function (int $value): void {
self::assertGreaterThanOrEqual(0, $value);
});
}

/**
* How many runs generated an in-range boundary value.
*
* @param \Closure(PropertyCheck): PropertyCheck $configure
*/
private function edgesSeen(\Closure $configure): int
{
$listener = new RecordingListener();

$configure(
$this->forAll(['value' => Gen::intBetween(-1_000_000, 1_000_000)])
->id('parity::edge-cases')
->runs(200)
->seed(5)
->listeners($listener),
)->check(static function (int $value): void {
self::assertIsInt($value);
});

$edges = 0;

foreach ($listener->events as $event) {
if ($event instanceof RunStarted && in_array($event->arguments['value'] ?? null, [0, 1, -1, -1_000_000, 1_000_000], true)) {
++$edges;
}
}

return $edges;
}

/**
* The seed the engine actually ran with, as the PropertyStarted event
* reports it — the one observable that says what a seed knob decided.
Expand Down
1 change: 1 addition & 0 deletions tests/Support/Env.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public static function isolateProperty(): \Closure
'PROPERTY_PHASES' => null,
'PROPERTY_DERANDOMIZE' => null,
'PROPERTY_PATH' => null,
'PROPERTY_EDGE_CASES' => null,
]);
}
}
Loading