Chainable type-safety assertion library with first-class PHPStan support. Each runtime assertion is paired with a PHPStan extension that narrows $v to the asserted type in the surrounding scope.
src/— runtime library code.Assert.php,Expectation.php— entry points.Assert::that()returns anExpectation<T>.Expectable.php— interface defining every assertion method (isInt,isString,hasOffset, etc.). The canonical surface; adding an assertion starts here.MutatingExpectable.php— interface for state-changing chain methods (not,nullOr,keys,values). Implemented only by the baseExpectation.Expectation.php— hand-written base; everything else is generated from it.NegatedExpectation.php,NullableExpectation.php,KeysIteratingExpectation.php,ValuesIteratingExpectation.php— all auto-generated, marked@auto-generated. Do not edit by hand.Exporter.php/ExporterInterface.php— value/type formatting for failure messages.src/Type/— PHPStan extension code (see below).src/Type/Resolver/— oneResolverInterfaceimplementation perExpectablemethod (IsIntResolver,HasOffsetResolver, etc.). Each builds the synthetic predicate AST for its method. Composite resolvers receive their dependencies via constructor injection (e.g.IsArrayKeyResolver(IsIntResolver, IsStringResolver)).StringDispatchingResolveris shared bycontains/startsWith/endsWith.
tools/— code-gen & devtools, not shipped at runtime.tools/src/ExpectationVariantsGenerator.php— generates the four variant classes.tools/src/ReadmeGenerator.php— generatesREADME.mdfromtools/resources/README.md.tpl.
bin/generate,bin/generate-readme— entry scripts for the generators.tests/— PHPUnit + PHPStan fixtures (see "Tests" below).extension.neon— PHPStan extension registration. NewType/services go here.
The canonical order is interface → base → regenerate → tests → resolver → fixtures. The resolver work belongs at the end, not before generation; it surfaces requirements (e.g. multi-arg predicates) naturally only once the runtime behaviour is in place and exercised.
- Interface. Add the method to
src/Expectable.phpwith full PHPDoc (@param,@return self<TValue>,@throws). - Base implementation. Add the method and its
MESSAGE_<CONSTANT>tosrc/Expectation.php. The constant message should use the default{value}/{type}placeholders unless a more specific shape is needed. - Regenerate variants. Run
composer generate:docs. This rewritesNegatedExpectation,NullableExpectation,KeysIteratingExpectation,ValuesIteratingExpectation, andREADME.md. Never edit those files by hand. There is a known catch-22: the generator loadsExpectation, which triggers PHP's interface-conformance check on the four variants, which still lack the new method. Bridge it by adding one-line stubs to each variant first, e.g.public function isFoo(...): self { return $this; }. The generator overwrites them. - Unit tests. Add a
test_<methodName>to all five test files (ExpectationTest,NegatedExpectationTest,NullableExpectationTest,KeysIteratingExpectationTest,ValuesIteratingExpectationTest). Method order in each file is enforced byExpectableAutoReviewTest. - PHPStan resolver. If the assertion needs type narrowing, add a
FooResolverclass insrc/Type/Resolver/implementingResolverInterface::resolve(Scope $scope, Node\Arg $arg, Node\Arg ...$args): Node\Expr. Wire it intoExpectationMethodResolver::__construct()'s$this->resolversmap under the method name. Conventions:- If the new resolver depends on others (e.g.
isListneedsisArray,isBetweenneedsisInt+isFloat), accept them as readonly constructor parameters and reuse the shared instances created at the top of__construct. - If the assertion is a simple alias whose narrowing is identical to another (e.g.
matchesRegularExpressionreusesIsStringResolver), point the map entry at the existing instance instead of creating a new class. - If the method needs a
FAUX_FUNCTION_*faux-call to disambiguate it in chained predicates (e.g.contains,startsWithboth narrow tostring), add its name toExpectationMethodResolver::METHODS_NEEDING_FAUX_WRAP. - If the message uses non-default placeholders, register them in
ExpectationVariantsGenerator::NON_DEFAULT_CONTEXT('value+'= type-exported,'name='= integrated as-is, plain name = value-exported) and re-runcomposer generate:docsso the variants pick up the new context. Note: integrated-as-is ('name=') markers must correspond to actual method parameter names; derived/computed context keys are not supported by the generator.
- If the new resolver depends on others (e.g.
- Type-inference fixtures. Add a
test_<method>to each of the five fixture files undertests/data/type-inference/withassertType(...)calls on both the assertion result and the narrowed value.ExpectableAutoReviewTest::testTypeInferenceFixturesCoverEveryMethodenforces coverage.
Finally run composer test:all and fix any drift (CS, PHPStan, unit, auto-review, type-inference).
The set of methods that are unreachable on PHP arrays when iterating keys (because array keys are constrained to int|string) lives in ExpectationVariantsGenerator::KEYS_UNREACHABLE_FOR_ARRAYS. Those methods throw \LogicException at runtime on arrays; they remain valid on non-array iterables.
Three extensions in src/Type/, registered in extension.neon:
AssertDynamicStaticMethodReturnTypeExtension— types the return ofAssert::that($x)asExpectationObjectTypecarrying the AST of$x.ExpectationDynamicMethodReturnTypeExtension— types the return of every chainable method on anExpectable. Mutating methods (not,nullOr,keys,values) wrap the existingExpectationObjectType; assertion methods narrow the wrapped type.ExpectationMethodTypeSpecifyingExtension— narrows the wrapped value's scope-type (the variable passed toAssert::that()), so callers see the narrowing on subsequent statements.
Both *MethodReturn* and *TypeSpecifying* extensions delegate to ExpectationMethodResolver. The resolver is an orchestrator that:
- holds a
method-name => ResolverInterfacemap built in its constructor (src/Type/Resolver/*are the implementations; see Layout above), - dispatches to the right resolver in
resolveExpr, which builds the per-method predicate AST, - handles negation / null-or wrapping around the predicate,
- accumulates predicates across the chain via
reduceExprWithStoredExprand thestoredExprcarried onExpectationObjectType, - wraps the predicate with a
FAUX_FUNCTION_<method>call for methods listed inMETHODS_NEEDING_FAUX_WRAPso they remain distinguishable in chained narrowings, - for iterating variants (
keys/values), runsnarrowIteratingto compute the rebuilt iterable type using a faux variable trick (see comment atnarrowIterating): the predicate is specified against a syntheticVariableso PHPStan's OR-handling cannot prune disjuncts as impossible against the iterable's outer type. UseExpectationMethodResolver::isIteratingVariant()for class-membership tests; don't inline the class list.
ExpectationObjectType is a custom GenericObjectType that carries (className, types, valueExpr, storedExpr). valueExpr is the AST of the original Assert::that($x) argument; storedExpr is the cumulative BooleanAnd of all predicates in the chain. These let mid-chain methods see and extend prior narrowings.
When a method's narrowing collapses to NeverType, both extensions return new NeverType(true) directly (not ExpectationObjectType<NeverType>), so PHPStan reports the call as unreachable.
Run with composer test:all (cs → phpstan → unit → auto-review → type-inference). Individual targets: composer test:unit, composer test:auto-review, composer test:stan.
Layout & conventions:
- One
test_<methodName>per publicExpectablemethod per file. - Unit tests for each
Expectableimplementer extendAbstractExpectationTestCase, which provides:assertNoErrorsThrown(\Closure $cb)— happy-path helper.assertExpectationFails(\Closure $cb, string $message)— failure-path helper with full-message match.
- Test files: one per implementer (
ExpectationTest,NegatedExpectationTest,NullableExpectationTest,KeysIteratingExpectationTest,ValuesIteratingExpectationTest). Method order follows the auto-review test (constructor → mutating methods in declared order → alphabetical). - For tests with multiple cases per method, use parameter naming
mixed $value1, mixed $value2, …and local names$assert1, $assert2, …(not$a, $b).
Type-inference fixtures live at tests/data/type-inference/:
expectation.php,negated-expectation.php,nullable-expectation.php,keys-iterating-expectation.php,values-iterating-expectation.php— onetest_<method>(mixed $value): voidper Expectable method, withassertType(...)calls on both the assertion result and the narrowed argument.chained-expectation.php— multi-step chain effects that aren't tied to a single method (e.g.isInt()->not()->isPositiveInt()).is-identical-expectation.php,is-instance-of-expectation.php— focused fixtures for non-typical methods.- Fixtures use bare functions in a per-file namespace
Nexus\Assert\Tests\TypeInference\<PascalCase>— not classes.
Auto-review tests (tests/ExpectableAutoReviewTest.php) act as drift detectors:
testExpectationMethodsAreArrangedInOrder— enforces method ordering on each variant class.testExpectationTestMethodsAreArrangedInOrder— same for the corresponding test class.testTypeInferenceFixturesCoverEveryMethod— everyExpectablemethod must have a fixture in each variant's fixture file. Detection uses aAssert::that(\(…\))->method(regex (recursive-paren) for the bareExpectationcase and a->variant()->method(substring for the others.
tests/ReadmeAutoReviewTest.php validates that the generated README.md is in sync with the template.
- Coding style:
composer cs:check/composer cs:fix. Config:.php-cs-fixer.dist.php(Nexus PHP 8.2 ruleset). Always passes before merge. - PHPStan:
composer phpstan:check. Level 10. Baseline atphpstan-baseline.php; regenerate withcomposer phpstan:baselineonly when an unavoidable false-positive accumulates — never to silence a real error. - The custom-error ignores section in
phpstan.dist.neonis reserved for test files that intentionally call already-narrowed types (method.alreadyNarrowedType,method.impossibleType). Don't add suppressions forsrc/. - Comments: explain why the code is non-obvious, never what it does. PHPDocs should not contain design rationale (move that to commit messages or PR descriptions); keep them to
@param/@return/@throws/@templateand one-line summaries. - Test coverage: 100% on
src/. Pipeline failures from missing coverage (e.g. uncovered foreach bodies on non-array iterables) should be fixed by adding tests, not by lowering the threshold.
Use tmp/ at the repo root for one-off PHPStan reproduction scripts or coverage inspection helpers. Don't drop them into tests/data/.
| Script | Purpose |
|---|---|
composer test:all |
Full pipeline: cs, phpstan, unit, auto-review, type-inference. |
composer test:unit |
PHPUnit @unit group with coverage. |
composer test:auto-review |
Drift-detection tests. |
composer test:stan |
PHPStan type-inference assertions. |
composer cs:check / cs:fix |
php-cs-fixer in check / fix mode. |
composer phpstan:check |
PHPStan analysis. |
composer phpstan:baseline |
Regenerate phpstan-baseline.php. |
composer generate:docs |
Regenerate all variant classes (bin/generate --all) and README (bin/generate-readme). |
Prefer these over invoking vendor/bin/phpunit or vendor/bin/phpstan directly so config is consistent.