Skip to content

Static analysis

The module is analysed at phpstan level 6, the level ai_disclosure uses, which is four steps above the level 2 the drupal.org template ships by default. Level 6 is what requires a value type on every iterable, and most of the typing work in the codebase exists because of it.

Configuration lives in two files. phpstan.base.neon holds the level, the analysed paths and every ignore rule. phpstan.neon.dist includes it together with bleedingEdge.neon from mglaman/phpstan-drupal, using the relative path that resolves from the module's real install location. Splitting them lets a git worktree supply its own include path without touching the committed file.

Two analysers, not one

The pipeline analyses the module twice, and the two runs are not equivalent:

Job Core phpstan
phpstan Drupal 11.4 2.2
phpstan (previous major) Drupal 10.6 1.12

The previous-major variant installs whatever phpstan the older core resolves, which is still the 1.12 series. Its inference is weaker in ways that matter: it does not follow an array shape changed through a by-reference foreach, and it cannot resolve container service ids. Code that is clean under 2.x therefore produces errors under 1.12 without anything being wrong with it.

Treat a difference between the two runs as a question about the analysers before treating it as a question about the code.

Why there is no baseline

When the previous-major job fails, the drupal.org template generates a phpstan-baseline.neon and offers it as an artifact. It has not been adopted.

A baseline suppresses a list of errors by file and count, with no reason attached, and it keeps suppressing them as the code moves. It also swallows new errors of the same kind that arrive later, which is precisely the signal the analysis exists to give. The 58-error baseline offered here would have hidden every entry below behind one commit that explains nothing.

Ignore rules are written by hand instead. Each one records what fires it, why the code stays as it is, and the condition under which the entry can be deleted. reportUnmatchedIgnoredErrors is false, so an entry that matches nothing on Drupal 11 is free: an entry being unused on the main run is not evidence that it is obsolete.

What is suppressed, and what is not

Read phpstan.base.neon for the full reasoning; this is the map.

What Count Root cause
PHPUnit\Framework\Attributes\* missing 32 Drupal 10 ships PHPUnit 9, which has no attribute classes. PHP ignores unknown attributes, so all 216 tests run and pass there.
FormBuilderInterface::getForm() arity 1 Drupal 10 declares the variadic in a comment; FormBuilder::getForm() reads it with func_get_args().
Array shape missing a key 3 phpstan 1.12 does not follow a shape set through a by-reference foreach. Two in the document builder, one in decorateRecapLabels().
Guards reported as always true 3 Types come from PHPDoc and treatPhpDocTypesAsCertain is on. The guards are reachable.
assertSame() inferred as impossible 1 phpstan 1.12 resolves neither service id and infers never on both sides.
FetchAs argument type 6 \PDO::FETCH_* is the only spelling valid across ^10 \|\| ^11; see below.

Two of these deserve emphasis, because the obvious reaction to both is to "fix" working code.

The cache-bin guard in CacheObserverEnvironmentSnapshotManager reads as dead. It is not: cache.page belongs to the page_cache module and cache.dynamic_page_cache to dynamic_page_cache, and either can be uninstalled. Deleting the guard turns a skipped bin into a container exception. The same applies to both guards in DatabaseSummarySpanSubscriber.

The FetchAs suppression is a compatibility decision, not an analyser quirk. Drupal\Core\Database\Statement\FetchAs arrived in Drupal 11.2, so FetchAs::Associative is a fatal error on every Drupal 10 and on 11.0 and 11.1. StatementBase::fetchAll() still accepts the integer constant and converts it with a deprecation notice, which makes \PDO::FETCH_ASSOC the only spelling that works across the declared range. It is revisited when the minimum core version rises past 11.2, a product decision, not a lint fix.

How the ECA dependency reaches CI

For a long time the analysis reported around twenty errors naming Drupal\eca\* symbols, reached from the two ECA submodules. Nothing was wrong with that code: ECA was simply absent from every CI job. Two of those errors were class.notFound, which phpstan marks non-ignorable, so no amount of configuration could have made the run pass, which is why the gate stayed off.

The fix is one line in composer.json:

"require-dev": {
    "drupal/eca": "^2.1 || ^3"
}

require-dev is where the ecosystem puts an optional integration: webform, search_api, commerce, ai and ECA itself all do the same, several of them keeping the package in suggest at the same time. It works because the drupal.org composer job expands the module's composer.json into the project root and then runs composer install, which installs a root require-dev. suggest stays alongside it: suggest speaks to sites, require-dev speaks to CI.

The constraint spans two lines deliberately. ECA 3 requires Drupal 11.3 and PHP 8.3 and cannot be installed on the previous-major variant at all; ECA 2.1 supports Drupal 10.3 and 11 on PHP 8.1 and carries every class the bridge uses. Composer resolves 3.x on Drupal 11 and 2.1 on Drupal 10, and neither submodule is enabled by a preset, so sites that do not use them need no ECA.

Do not move this to require. That would oblige every site installing the module to pull ECA in for one optional submodule.