Skip to content

Static analysis

The module is analyzed 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 three files. phpstan.base.neon holds the level, the analyzed 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. phpstan-previous-major.neon.dist includes phpstan.neon.dist unchanged, for the previous-major run.

Two analyzers, not one

The pipeline analyzes 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 analyzers before treating it as a question about the code.

The previous-major job runs on PHP 8.3. The MCP submodule's dev dependencies (mcp_server) need PHP 8.3, so the PHP 8.2 variant can no longer be installed next to them, although the base modules still declare PHP 8.2.

The MCP OAuth decorators

modules/native_observability_mcp/src/OAuth/ is excluded from the analysis, and the MCP Kernel tests carry a few ignore rules. Those classes implement interfaces of league/oauth2-server and simple_oauth, which are suggested and not required, and the previous-major job cannot install simple_oauth (ext-sodium is missing there). The CI composer job adds the OAuth packages as dev dependencies after the standard install, so the MCP tests run on the current major instead of skipping. They skip themselves when the packages are absent.

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 the 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.
Drush\Style\DrushStyle unknown class 35 Previous-major variant only: the Drush 13.6.2 the PHP 8.2 runner resolves lacks the class that DrushCommands::io() declares. Every method called belongs to SymfonyStyle, which it extends.
TerminalResultPrinter $io argument 5 Same root cause, seen from the other end: phpstan cannot prove a DrushStyle satisfies the printer's parameter type. Previous-major variant only.

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 result sets are read with fetchAssoc() loops rather than fetchAll() with a fetch-mode argument, so no code depends on \PDO::FETCH_* or on the FetchAs enum (Drupal 11.2 and later), and nothing about it needs suppressing.

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.