Search by

cruciblephp / phpstan-crucible

lucianofedericopereira

PHPStan rules for code under test: a #[\SensitiveParameter] value never reaches a PSR-3 log, and a method no test runs is reported from Clover or Crucible coverage

Package info

github.com/cruciblephp/phpstan-crucible

Type:phpstan-extension

pkg:composer/cruciblephp/phpstan-crucible

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-11-15 07:43 UTC

This package is auto-updated.

Last update: 2026-11-15 07:58:02 UTC


README

PHPStan rules for code under test. They need no particular test runner and do not install one: one reads your code, as any PHPStan rule does, and one reads the Clover coverage PHPUnit and Pest already write. Crucible is one more source of data, never a requirement.

Rule Reads Works with
sensitiveParameter.logged your code any PSR-3 logger
coverage.untestedMethod Clover XML, or Crucible's cache PHPUnit, Pest, Crucible, any runner that writes Clover
observedType.iterableValue Crucible's observed-types.json Crucible's --observe-types

Install

composer require --dev cruciblephp/phpstan-crucible

With phpstan/extension-installer the rules load by themselves. Without it, include the extension in phpstan.neon:

includes:
    - vendor/cruciblephp/phpstan-crucible/extension.neon

Requires PHP 8.3 and PHPStan 2.3.2. Its only dependency is PHPStan.

A sensitive parameter never reaches a log

PHP 8.2's #[\SensitiveParameter] keeps a value out of stack traces. A log line puts it back in a file. This rule reports a parameter marked with it that reaches the message or the context of any PSR-3 logger: Monolog, Laravel's, or your own Psr\Log\LoggerInterface.

public function attempt(string $user, #[\SensitiveParameter] string $password): void
{
    $this->logger->warning('login failed', ['user' => $user, 'password' => $password]);
}
Parameter $password is marked #[\SensitiveParameter] and reaches the context of warning().
💡 Log a reference to it instead, or leave it out of the log.
🪪 sensitiveParameter.logged

The value counts where it flows as it is:

  • concatenated, interpolated, or as a branch of ??, ?: or match;
  • cast to a string, an int or an array, or as an element or property of it;
  • as an array value of the context (an array key is a name, not a value);
  • through sprintf(), vsprintf(), implode(), join(), json_encode(), var_export(), print_r(), serialize(), strval() or trim();
  • copied into another variable first, $copy = $password, or appended to one, $line .= $password.

Anything that only looks at the value yields something else and is not reported: a comparison ($password !== null), isset(), empty(), !, a (bool) cast, count(), hash('sha256', $password), strlen($token), or any other call.

An assignment of something else replaces the value when it is a statement of the function body itself, the one that always runs. Inside a branch or a loop it may not have run, and the value is still followed. Arguments are read by name when they are named, info(context: [...], message: '...'), and a nullable logger counts as the logger it is.

A closure's own sensitive parameters count, and so do the ones it captures with use; an arrow function sees those of the function it is written in.

Not covered here

How a logger is called is checked by staabm/phpstan-psr3 (interpolated variables in the message) and sfp-phpstan-psr-log (placeholders against context keys, the exception key). This rule answers a different question: whether the value the language already marks as secret is written down.

A method no test covers

Point the rule at the coverage your test runner already writes, and every method with executable lines that no test covers is reported:

parameters:
    crucibleCoverage:
        path: build/clover.xml
vendor/bin/phpunit --coverage-clover build/clover.xml   # or: vendor/bin/pest --coverage-clover build/clover.xml
vendor/bin/phpstan analyse
Method App\Cart::clear() is not covered by any test.
🪪 coverage.untestedMethod

Any runner that writes Clover XML works. Clover counts what the runner credits to each test, so with PHPUnit's and Pest's #[CoversClass] a method only another class's tests run is not covered. Crucible also writes .crucible.cache/coverage-lines.json on every --coverage run: it records what each test ran, whatever it declares to cover. Point the rule at the directory (path: .crucible.cache) to read it. The format is specified in Crucible's spec/cache-formats.md, so any tool can write it.

A file whose lines may have moved since the coverage was written is left alone, never reported on its old line numbers. Crucible's map records each file's hash; a Clover file older than the source is not trusted for it. Abstract methods and methods with no executable line are not judged. A trait's method is reported once, under the trait's name, however many classes use it.

The analysis depends on the coverage file: a new one re-analyses the files it describes, even from PHPStan's result cache, and so does one written after it was configured.

Without crucibleCoverage.path the rule does nothing.

The type an array's docblock leaves out

PHPStan asks for an array's value type (missingType.iterableValue) and cannot say what it is when the data comes from json_decode(), a database or a framework. A test run has seen it. crucible --observe-types writes what every function received and returned to .crucible.cache/observed-types.json (format in spec/cache-formats.md); point this rule at it:

parameters:
    crucibleObservedTypes:
        path: .crucible.cache
Parameter $rows of App\Report::render() names no value type; the tests passed non-empty-list<array{id: positive-int}>.
💡 @param non-empty-list<array{id: positive-int}> $rows
🪪 observedType.iterableValue

Only a slot PHPStan itself would call untyped is answered: an array or iterable whose value type no docblock states, the method's own or one it inherits, and only with the types the tests were seen passing or returning. What the tests saw is evidence, not the contract: widen it where the code takes more than the tests send.

Without crucibleObservedTypes.path the rule does nothing.

Development

composer check   # Pint, Rector, PHPStan max with strict and deprecation rules, the suite

The suite runs the real PHPStan binary over fixtures and reads back its JSON, as a project would run it.

License

MIT