Repository navigation
Releases: rasuvaeff/property-testing-core
Release list
v1.2.0
- Added:
#[Generate]— a parameter attribute holding its generator, read by
Gen::forParameters()andGen::forClass()after an override and before the
docblock and the native type (#160). It takes an arbitrary built withnew, 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 withoutauto. - Added: the docblock reader understands
numeric-string,
non-falsy-string/truthy-string,lowercase-string,
non-empty-lowercase-stringand array shapes —array{a: T, b?: U},
quoted and integer keys, positionalarray{T, U}andlist{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@paramtype written across several lines is now read. - Changed: refusals about a parameter now end with
pass an override or #[Generate].
v1.1.0
- Changed: a shrink candidate is accepted only when it fails the same way
the original run did — the same exception class and the same place in the
property's own file. A body that can fail in more than one place (two
assertions, or an assertion beside a call that throws the same type) used to
offer the descent failures it could not tell apart, so the reported minimal
counterexample could belong to a bug the run never found, with nothing in the
output saying so. The place is the line of the property's own file in the
failure's stack, which is not where the exception was raised: an assertion
library raises every assertion of a body from one line of its own, while the
line that called in is the assertion the body wrote. Where the place cannot be read —
an id that names no loaded class, a body written in another file (a trait, an
included fixture), a failure the engine raised rather than the body — the
exception class stays the whole criterion. Minimised counterexamples may
therefore differ from 1.0.0 on such a body (compatibility policy §2: what a
descent minimises to is not under SemVer); a corpus recorded before this
release replays unchanged. Worth knowing for stateful properties: the
rule-based façade's#[Rule]methods live in the test file, so a command
sequence whose failure moves from one rule to another now stops the descent
rather than being minimised into the other rule's bug.
v1.0.0
The stability release. The @api surface is that of 0.12.0, unchanged: no
type, method or parameter was added, removed or renamed, and a project on
^0.12 upgrades by widening the constraint to ^1.0. What the number
promises from here on is written out in the compatibility policy
(README.md, docs/src/guide/compatibility.md): the @api surface, the
corpus format (FilesystemCorpus::FORMAT_VERSION does not change within
1.x), the machine-readable keys of CounterExample::toArray() and
DistributionReport::toArray(), event fields and order, append-only
constructors — while seed → values stays outside SemVer, fenced by
FilesystemCorpus::SEQUENCE_EPOCH. The Gen::* parameter names are frozen
as they stand: $minLength/$maxLength for strings, bytes and sequences,
$minSize/$maxSize for collections, $min/$max for ranges.
- The adapters (
rasuvaeff/property-testing-testo,
rasuvaeff/property-testing-phpunit) andrasuvaeff/property-testing-names
release their own1.0.0requiring this package as^1.0, in that order
(policy §8). - Docs: the version gates that dated each feature (
core ≥0.12,
-testo ≥0.6,-phpunit ≥0.5, …) are gone from the skill, the guides and
the migration document — every 1.0 install has all of them. The migration
recipe requires-testo:^1.0. composer rectoris green again: the three seed-hunting loops in
CompositeArbitraryTestshare onefirstGenerated()helper instead of a
nullable sentinel thatFlipTypeControlToUseExclusiveTypeRectorrewrote.
v0.12.0
- Added:
Gen::uniqueArrayOf(..., by:)— uniqueness by theint|string
key a closure returns per element, for value objects unique by one field;
a key of any other type is refused at generation time rather than compared
by identity. Value-based uniqueness is unchanged, sequence and all (#146). - Added:
Gen::withEdgeCases($inner, ...$edgeCases)— per-generator
boundary values: one draw in five is one of them, they come first in the
shrink order, and the bias stays on underEdgeCases::None. The wrapper
rolls on the run's randomness and leaves the inner sequence untouched, so
the sequence epoch does not move (#144). - Added:
Gen::note($label, $value)— a computed value attached to the
run, carried on the counterexample as$originalNotes/$shrunkNotesand
rendered as aNotes:line for the minimised run.CounterExample::toArray()
gains the two keys, always present (#142). - Added:
Classify::tabulate($table, $tags)— per-run categories a run
may belong to several of, tallied per table with the pairwise intersections
of tags hit together, onRunStatistics/DistributionReport::$tablesand
$intersections;toArray()carries atableskey only when a run
tabulated (#149). - Added:
Gen::randomEngine()/Gen::randomizer()— aRandom\Engine
(and theRandomizerover it) whose output is drawn from the property's
tape, so code that takes a randomizer has its random decisions recorded,
replayed and shrunk toward"\0";Gen::forParameters()derives both from
the native parameter types (#141). - Added:
Gen::composite(Closure(Draw): T)— several dependent draws in
one reusable generator, shrinking the draws earliest first with later draws
re-drawn through the new range from a stream of their own position; the
descent is capped like in-body draws (#148). - Added: exhaustive mode —
PropertyConfig::$exhaustive/
$exhaustiveBudgetwalk the whole parameter product instead of sampling
when every generator implements the newEnumerableseam
(domainSize()/enumerate(): constant, bool, int, oneOf/elements/enum,
nullable, tuple, record, map, filter, withEdgeCases) and the product fits
the budget; the walk is seed-independent, andRunStatistics/
DistributionReportreport$domainSizeor$exhaustiveDeclined(#147). - Added: flaky detection —
PropertyConfig::$flakyReplays(default 2)
re-executes the minimised counterexample; a replay that passes marks it
flaky (CounterExample::isFlaky(),$replays,$passedOnReplay, a
Flaky:line in the message). No new events; wall clock only (#145). - Added: rule-based stateful façade —
#[Rule],#[Precondition],
#[Invariant]on one machine class,Gen::rules($machine)producing a
RuleSequenceofRuleSteps over the existing command-sequence generation
and shrinking;RuleSequence::run($factory)builds a fresh machine per run
and keeps the factory out of the value (#143). - Added: targeted search —
Target::maximize()/minimize()report a
score,PropertyConfig::$searchRunsopens a hill-climbing phase after the
random one (one parameter regenerated at a time over a pool of the best
inputs),TargetImprovedannounces every new best,SearchReporton
PropertyFinished/RunStatisticssums it up, and the newSearchCorpus
seam (implemented byFilesystemCorpusandRedisCorpus, in a separate
search document) keeps the pool between runs. Measured on a corner bug at
equal budget: 52 → 98 of 100 seeds; no gain, no loss where the score does
not lead to the bug (#150). PropertyFinishedgains a fourth constructor parameter,$search;
RunStatisticsgains$tables,$intersections,$domainSize,
$exhaustiveDeclined,$search;DistributionReportthe matching
fields;CorpusFailed::$operationmay now berecallTargetsor
rememberTargets. All appended with defaults.- Added:
EnvironmentOverrides::count($variable, $value)— a
non-negative integer for count-shaped variables such as
PROPERTY_SEARCH_RUNS, where0is a valid "none". - The adapter contract suite pins the path version at
0.11.0, which both
adapters accept; it sat at0.10.0. - A refusal over a docblock type no longer calls a generic the function or
its class declares (@template T) an unknown class:list<T>is reported
as unreadable, and only a name nothing declares asunknown class "…". docs/.api-workspacemoves its path version for core to0.11.0and the
adapter constraints to-testo^0.11/-phpunit^0.9, the bridged
releases (0.11.1 / 0.9.1) that accept core 0.11.
v0.11.0
- Fixed:
RedisDsn::parse()quoted the password when the DSN carried
userinfo but no host (redis://user:s3cret@,redis://user:s3cret@:6379):
parse_url()returnsfalsethere, and the userinfo check trusted its
answer. Credentials are now looked for in the raw authority before anything
else is read, so the "carries credentials" message is the one you get, and it
never echoes the DSN (#128). - Fixed:
floatBetween($min, $max)shrank to$maxwhen the range lay at
or below zero — the one value the generator never produces. The shrink target
is the point of[$min, $max)nearest to0.0: for such a range the largest
float under$max. Generation is unchanged;SEQUENCE_EPOCHstays (#129). - Fixed:
FilesystemCorpusno longer skips a write in silence. A lock file
replaced by a symlink, an occupied temp path, a short write, a failed rename
or a document held by another format version now throwRuntimeException,
which the runner reports asCorpusFailed;CorpusStoredis emitted only
once the document is on disk. The property's outcome is unaffected, as
before (#136). - Fixed:
PropertyRunner::run()dropped listeners from an iterable that
repeated keys —iterator_to_array()kept the keys (#134). PropertyTestingException: an empty marker interface (extends\Throwable)
implemented by all ten@apiexceptions, socatch (PropertyTestingException)
catches whatever the engine reports.AssumptionSkippedstays outside it on
purpose — a discard is control flow, not a failure (#130).AssumptionSkippedis@api: everyTrialExecutorhas to catch it to map
Assume::that()toTrialOutcome::discarded(), and the executor seam is
documented as such (#131).Gen::forClass()/Gen::forParameters()read@psalm-paramand
@phpstan-param(winning over@param) and the@varwritten on a promoted
constructor property when the constructor docblock says nothing about it.
/** @var int<0, max> */ public int $amountno longer generates from the
nativeint(#132).Gen::stringOf()defaults its bounds to0..100, likestringFrom()and
bytes()(#133).new FilesystemCorpus('')and a Redis DSN with a#fragmentare refused with
InvalidArgumentExceptioninstead of writing under/or dropping the
fragment (#135).- Refusal messages name what they refuse: a native union or an untyped
parameter is reported astyped int|string/untypedrather than "with no
usable type", an unknown class inside a docblock type is named (unknown class "Nope"), and an enum without cases orGen::elements([])says which
enum or argument is empty instead of "OneOf requires at least one value". - Documentation:
llms.txtand the README tables name theGen::*parameters
as the signatures do ($minSize/$maxSize,$map,$flatMap,
$predicate,$arbitrary); the frozen keys ofCounterExample::toArray()
andDistributionReport::toArray()are listed and pinned by a test; a corpus
seed entry replaying withoutPhase::Random,timeoutMsnot judging
discarded or skipped runs, the engine never warning about the discard share,
a nativefloatmeaningfloatBetween(-1e6, 1e6),dictOfredrawing
integer-like string keys,floatBetween($x, $x)andNANunder
uniqueArrayOfare written down.composer-require-checker.jsonis
export-ignored. docs/.api-workspacemoves its path version for core to0.10.0and the
Testo adapter constraint to^0.10, now that the adapter's^0.9 || ^0.10
bridge is released (0.10.0). The 0.10.0 release notes explain why the path
version had to stay at0.9.0for a day.
v0.10.0
The contract freeze ahead of 1.0. Everything here is something that could not
be changed after 1.0.0 without a major, so it is changed now. The new
Compatibility policy says what the numbers
will promise from then on.
- Breaking:
CounterExample::$skipsis now$discards, and$skipsis a
real, separate counter — the runs the environment refused. The two were one
word for two quantities: the field heldAssume::that()discards, while
RunStatistics::$skips(0.9.0) held environmental skips, in the same@api.
toArray()/toJson()gain adiscardskey besideskips. The corpus is
untouched —CorpusDocumentnever wrote the fulltoArray(). - Breaking:
FilesystemCorpus::fromEnv()is removed. It readPROPERTY_DB
and builtnew self($directory)unconditionally, soPROPERTY_DB=redis://…
created a directory literally namedredis:/host:port/…and emitted four
E_WARNINGs per call from the stream-wrapper lookup — a silent fall back to
the filesystem, which is exactly what the family forbids. Use
CorpusFactory::fromDsn(EnvironmentOverrides::string(getenv('PROPERTY_DB')) ?? …),
which cannot return a backend the value did not name. Nothing insrc/calls
getenv()any more. - Breaking:
GenerationExhaustedisGenerationExhaustedExceptionand
StateMachine\PostconditionViolationis
StateMachine\PostconditionViolationException, matching the seven public
exceptions that already carried the suffix. No aliases: 0.x is where a rename
costs a line in an upgrade note rather than a major. - Breaking: every
Gen::*factory returnsArbitraryInterface<T>instead
of a concrete arbitrary. The concrete classes stay@apiand unchanged —
they are simply no longer part of the facade's signature, so an
implementation can be replaced without a major.Gen::enum()bounds its
template at\UnitEnumandGen::intRange()declares the honest
array{int, int}it produces. - Breaking: a flag environment variable is off for
0,false,offand
no(case-insensitive, trimmed), not only0and''.
PROPERTY_VERBOSE=falseturning verbose output on was 2.8-compatible and
indefensible. - Breaking: left implicit, the skip budget is
runsrather than the
discard budget'sruns * 10. A property skipped on every run gives up after
runs + 1attempts instead of10 * runs + 1— under Testo that is a
#[BeforeTest]hook executed 101 times rather than 1001 for aruns: 100
property. An explicitmaxDiscardsstill governs both budgets; transient
skips still do not end a property early. RunDiscardedcarriesbool $skipped, so a listener can tell an
environmental skip from anAssume::that()discard. Both adapters'
VerboseListenercounted them as one.DistributionReportcarriesint $skipsand reports it intoArray().
discardPercent()keeps dividing byattempts— that denominator is the
point of the name.GaveUpExceptioncarries?int $maxSkips, the cap the skip budget was
measured against; the skip message names it. Null means the caller did not
tell the two budgets apart, and the message falls back to$maxDiscards.- Redis
AUTHis reachable:RedisDsnandCorpusFactory::fromDsn()take an
optional password,LazyPhpRedisCorpusClientauthenticates beforeSELECT,
andtoPredisParameters()carries it. Adapters readPROPERTY_DB_PASSWORD.
The DSN's userinfo is still refused —PROPERTY_DBis echoed in diagnostics
and lands in CI logs. RedisCorpusrefuses an empty prefix and a negativemaxValues/maxSeeds.
A negative cap madeCorpusDocument::cap()drop every entry in silence.composer rectoris green:PropertyRunnerandRegexCompilername two
literal arguments. It had been red since 0.9.0, which iscomposer release-checkred —composer builddoes not run rector.- Documentation is part of the contract, and this release closes 37 places
where it disagreed with the code: the split Configuration table in both
READMEs,forAll($generators, $id)(the id comes from->id()),
floatBetween's interval (half-open, and it had no description at all), the
four-of-eleven examples tables, the four-of-nine environment variables page,
the eight-of-fifteen attribute table,Command::shrinks()andminLen,
PSR-20 as the adapters' clock,path"when it ships in 0.2",CorpusFromEnv,
ext-tokenizermissing from the requirements, and Redis absent from the
regression-corpus page entirely. The site gained a Compatibility policy
page,roadmap.mdno longer promises a seed sequence that 0.6.0 changed, and
the API reference reflects traits — so the PHPUnit adapter's single entry
point, thePropertyTestingtrait, has a page for the first time. docs/.api-workspacefollows the adapters again (^0.9/^0.7; it sat at
^0.6/^0.5, and a caret on0.xpins a minor, so the weekly docs rebuild
had been re-reflecting the same stale release). Keeping it in step is now in
the release checklist besidebuild.yml's pin. At release timebuild.yml
moves to0.10.0and the workspace to^0.9/^0.8, but the workspace's
path version stays0.9.0for now: the published Testo adapter still
constrains core to^0.9(its^0.9 || ^0.10bridge is merged, not
tagged), and a0.10.0path version would make the workspace uninstallable.- The Compatibility policy gains the point the 1.0 review asked for and the
freeze first left out: how data is reached is frozen as it stands. The
accessor style is not uniform — results, events and most exceptions expose
publicreadonlyproperties, whilePropertyViolationException,
ExampleViolationException,PathViolationExceptionand
RegressionViolationExceptionexpose getters — and it stays that way. Unifying it would break every consumer for a cosmetic
gain, and the split is at least stable: a type either has the field or the
getter, and one never becomes the other. Written down rather than left to be
rediscovered. ValueCodecrefuses an int-flagged array key whose text is not a decimal
integer the platform holds: a bare(int)cast saturated9223372036854775808
toPHP_INT_MAXand collapsedabcto0, replaying a key the property was
never called with. The lenient007→7reading stays (#119).ValueCodecreads a finite float back only in the shapevar_export()writes
it:1e999(cast toINF),1.0E-400(cast to0.0),01.0,.5and
whitespace-padded texts were accepted byis_numeric()and decoded to a
value the encoder could never have stored (#120).FrequencyArbitraryreports weights whose sum passesPHP_INT_MAXas an
InvalidArgumentException; the float sum used to reach the typed property
and fail with aTypeError(#121).PropertyDefinitionrejects a repeated parameter name — the runner combined
names and draws by name, so one draw was dropped without a word and the
body received fewer arguments than declared (#122).DateTimeArbitraryrefuses a bound outside the microsecond range of a
64-bit integer (about 292 000 years either side of the epoch) with an
InvalidArgumentException; the overflowed product used to fail theint
return type with aTypeError(#123).
v0.9.0
- Breaking:
Gen::oneOf()andGen::elements()reject an
ArbitraryInterfaceamong their values.oneOf(generator, generator)is how
fast-check, jqwik and Hypothesis spell "pick one of these generators", and
here it made the generator objects themselves the data: the body received
IntArbitraryandStringArbitraryinstances, never a generated value, and
the property reported green having checked nothing. Pick between generators
withGen::frequency();oneOf/elementstake values. - Breaking:
Gen::regex()/Gen::stringMatching()reject a delimited
pattern. The compiler always wanted[a-z]{3,6}, not/[a-z]{3,6}/, and the
word "delimiter" appeared nowhere in the code or the documentation. A PHP
developer arrives with a pattern frompreg_match(), which is always
delimited, and got one of two things: an anchored pattern was blamed on its
anchor (the^is not leading only because a/precedes it), and an
unanchored one compiled with the delimiters as literals — values that still
passed an unanchoredpreg_match(), so the property stayed green while every
generated string carried two junk characters. The rejection names the
undelimited pattern to paste back, and escaping the character still matches
it literally. - Environmental skips no longer spend the discard budget, and
RunStatistics::$skipscounts them apart from$discards. A skip says
nothing about the input, so charging it to the discard budget made a machine
missing a dependency give up — and be advised to narrow generators that were
never at fault.GaveUpExceptiongained$skippedRunsand
$exhaustedBySkips, and reports the environment rather than the generators
when it is the skip budget that ran out. Both budgets share the
maxDiscardscap.
v0.8.0
TrialOutcome::skipped()joinsdiscarded()as a third "this run checked
nothing" outcome, and adapters should report an environmental skip
(markTestSkipped(), a framework skip from a lifecycle hook) as such. It
counts as a discard everywhere but one: a recorded regression whose replay
only skipped is kept instead of pruned. Before, one machine without the
dependency the body guards against deleted the counterexample for every
machine that has it.- A shrink-path replay no longer accepts
GenerationExhaustedas the step's
falsification. A draw past the end of the recorded tape regenerates through
the live generator, and an exhaustible one (filter(),uniqueArrayOf()
with a minimum,commands()with a minimum length) can fail to produce a
value: the recorded bug was reported as a generator exhaustion. The step is
now reported as a stale path. prune()says so — as aCorpusFailedevent — when the entry cannot be
re-encoded to the key that identifies it, on both the filesystem and the
Redis backend. It used to compute a null key, match nothing and silently
replay the entry forever.Gen::forParameters()/Gen::forClass()refuse aDateTimeInterfacetype
that is notDateTimeImmutableitself — a subclass, orDateTime— by name.
Reflection over the inherited constructor asked for astringand blew up
with a date-parse error deep inside the recursion instead.- A Redis DSN with an array-valued
prefix(?prefix[]=x) is refused rather
than silently falling back to the default prefix, and a database index with a
leading zero (/01) is reported as the spelling mistake it is instead of as
a number outside the integer range. - The corpus file is read through a suppressed
file_get_contents(), like
every other filesystem call in that class: a file removed between the
is_file()check and the read no longer prints a raw warning into the
suite's output.
v0.7.0
PropertyConfigrejects atimeoutMsorbudgetMspast the value where the
millisecond-to-nanosecond conversion leaves the integer range, the way it
already rejected such ashrinkBudgetMs. Above that bound the deadline
silently stopped being one.- A corpus document whose keys collide once PHP normalises them —
0and"0"
in one encoded array, which no real PHP array can hold — is refused instead of
losing a pair toarray_combine(). Gen::regex()strips a trailing$anchor by the parity of the backslash run
before it, sofoo\\$compiles (literal backslash, real anchor) instead of
failing on the anchor it mistook for an escaped$.- A quoted literal in a psalm docblock type may contain the separator of the
type it sits in:'a|b'|'c'andarray{k: 'x,y'}are read as written, and
\'inside a literal is unescaped. Gen::oneOf()enumerates shrink candidates for objects by identity. The
previousvar_export()key threw on a cyclic object graph and treated two
distinct objects of equal state as one candidate.Gen::forClass()builds itsReflectionClassonce instead of once per
generated value.
v0.6.1
- Redis corpus DSNs accept a finite connection
timeout(default5.0),
passed to both Predis and ext-redis clients. - Redis DSN ports and database indexes are validated strictly; invalid and
overflowing values are rejected instead of being silently changed.