Dependency wiring static analysis cannot see
A generated map of dependency-injection wiring turned a boot-order fault the unit suite could not reach into something a test can check.
- PHP 8
- React 18
- TypeScript
- tree-sitter
- PHP tokenizer
Case study - a European multi-tenant SaaS platform for AI chat and voice agents.
Problem
Runtime dependency-injection wiring is invisible to static analysis. A call-graph tool cannot say what implements an interface when the answer is decided at registration time, so the codebase cannot answer that question about itself, and neither can an AI coding assistant reasoning over it. A generated map of the wiring was built to close that gap in the code index those assistants query.
The same wiring hides a failure mode. The container registers eagerly, so an interface registered after the binding that needs it makes the application fail before it finishes booting, and the unit suite cannot reach it by construction.
I was the lead developer of this production multi-tenant SaaS platform, and for long stretches the only one. What follows is the map, and the check that was later written against it.
Constraints
- Solo maintenance bandwidth: every piece of tooling had to pay for itself.
- A dynamic-language stack (PHP with a runtime DI container, React and TypeScript) that defeats conventional call-graph tooling.
My responsibility
I architected, built and operated the platform. The overwhelming majority of the codebase is my own work, and effectively all of the backend, multi-tenancy, billing, telephony, deployment and test infrastructure; one colleague contributed a scoped frontend feature and QA passes. The work in this study is mine as well - the generator, its CI gate and pre-commit hook, and the order check - built with AI coding assistants.
Making implicit wiring answerable
The runtime DI container resolves interfaces dynamically, so the bindings do not appear in that tree-sitter code index. I wrote a read-only generator that parses the container-registration source with the PHP tokenizer, never executing the application, and emits a committed JSON and Markdown map of every binding: interface, implementation, shape, dependency list. A CI job regenerates the map on every relevant change and fails the build if the committed map is stale.
A pre-commit hook regenerates the map and re-stages it whenever the registration source is staged.
What the gate detects is a difference between the committed map and what the generator produces from the current source. What it does not detect is a binding the parser cannot see, and that blind spot is wider than it looks. Registration inside a loop, a binding id held in a variable rather than written as a literal, a factory delegating to a method: some are dropped silently, some are recorded confidently and wrongly. None has ever been hit, because none of those styles appears in the registration source. The limit is real and latent, and it stays latent exactly as long as the code style does. One inaccuracy is live: a dependency gated behind an environment check is recorded unconditionally, so the map over-reports it.
What the map actually earned
The container’s singleton() is eager: it invokes the factory at registration time. A factory resolving an interface registered further down the file falls through to autowiring, which reflects the id and calls new on it. For an interface, new throws, and the application fails on every request before it finishes booting.
Two of these were found the slow way, in development. The unit suite could not have caught either, by construction: every unit test injects its dependencies as mocks, so registration order does not exist in a unit test. A map-based test already existed, and it passed throughout. It asserted that bindings were present.
The response was not a better gate. It was a different question asked of the same artefact: an assertion that no eager dependency autowiring cannot construct is registered after the binding that needs it. Worth noting what the map was built for - feeding the code index, so coding assistants could see wiring that static analysis cannot. Nobody designed it as a safety net. It became one because a machine-readable description of the wiring was sitting there to be asked.
Building it meant fixing the generator first. The root registration method calls a feature-registration method from its middle, while that method’s body sits four hundred-odd lines further down the file. Concatenating method bodies in source order put every feature binding after the scheduled-task manager, when in execution they are registered before it. Ordering by line number produced thirteen violations against an application that booted fine, and nine of them were phantoms. A check with nine phantom failures does not get fixed. It gets relaxed until it means nothing. So the parser now splices each sub-method’s records in at the line where the root calls it, and a sub-method nobody calls is appended with a note saying its order is unverified rather than dropped, because the binding is real even when its position is not known.
Then the check was validated in both directions: red against the commit at which the application did not boot, green against the fix, and re-run that way after every later change to the predicate. A checker reporting zero is worth nothing until it has reddened on a known-bad commit.
It caught the third instance, a service registered before the configuration interface its registration resolves.
Two decisions inside the check
The predicate does not use the map’s own id_is_interface field. That field is derived from a naming convention; the predicate instead asks the language whether the id can be constructed at all, because constructibility is what autowiring actually turns on. It fails closed, treating an id it cannot autoload as fatal rather than tolerable.
The non-fatal half is a ratchet rather than a rule, and says so. The excerpt below is abridged: class identifiers are renamed, the count is stated in the past tense, formatting is simplified, and cuts are marked […]. The method name and the reasoning are as written.
/**
* The non-fatal half: a RATCHET, not a rule, and labelled as one.
*
* IT IS NOT ZERO AND THIS IS NOT AN ALLOW-LIST. Four eager forward
* references to CONCRETE classes existed when this was written, all
* pre-existing [...]:
*
* ApiGatewayService -> PrimaryDefaultsResolver
* ApiGatewayService -> SecondaryDefaultsResolver
* ICostProcessingServiceInterface -> FetchCostService
* RouteRegistry -> SettingsController
*
* They boot, because autowire() can construct a concrete class. What they
* cost is quieter: the autowired instance is returned WITHOUT being stored
* in $services, so the later singleton() registration still runs and builds
* a second one. [...]
*
* Naming the four pairs and passing them would be the copy-versus-post-
* condition mistake: the test would then assert its own list. So the
* assertion is on the COUNT NOT GROWING. A fifth fails the row; fixing one
* leaves it green and the number here should come down with it.
*/
public function testNoNewConcreteEagerForwardReferenceIsIntroduced(): voidThat comment is wrong, and it is the shipped one, so it is worth being exact about where. For detection a named baseline beats a count outright: fix one of the four, introduce a fifth somewhere else, and the count is still four and the test stays green, unless someone follows the comment’s instruction to lower the number, which nothing enforces. The method above is called testNoNewConcreteEagerForwardReferenceIsIntroduced, and “no new one is introduced” is a claim about a set difference. A count cannot check it. The name was right and the body was not.
The justification is worse than the choice it defends, because it does not survive being applied to itself. “Assert its own list” describes an expectation derived from the value under test - a snapshot regenerated on failure. But the 4 was read off the current output exactly as the list would have been. Both are literals typed into the test by hand, and neither is computed from the thing being checked. Held consistently, the principle condemns the option it chose. What it was reaching for is true and worth keeping: a baseline regenerated from current output is vacuous, and a written one is not. Nor does maintenance rescue the count, since bumping four to five is as cheap as appending a name.
What the ratchet did buy is narrower and still real. None of the four are in the subsystem under test, so it works as a debt ceiling on another subsystem’s debt: do not make this worse, without taking on the curation of unrelated pre-existing problems. And the gap is bounded, because only the non-fatal half is a ratchet. The fatal half is zero. The swap that defeats the count can therefore only hide one concrete-class forward reference behind another, and that costs a duplicated instance rather than a failed boot.
Which is the uncomfortable part of a study about writing a check. Writing the test came close: its own comment says fixing one leaves it green and the number should come down. It did not say what happens when nobody lowers it and another one arrives. A reader asking what happens if you fix one and add another did.
Two limits stay on all of this. The detector reads the committed map, so it sees the problem after the commit that introduces it, not before. And it verifies declared order from a generated description, not a real container boot: a factory reaching the container by a route the generator cannot see is invisible to it, which is the parser’s blind spot again, one level up.
Trade-off
- Committed generated artefacts vs. live queries. Committing the DI map adds merge noise and a CI gate, but makes the record reviewable, diffable and available to any tool with zero runtime. I chose auditability.
Results
- A pre-commit hook regenerates the map with the source that changed, and a CI gate fails the build if the committed map is stale.
- A boot-order fault the unit suite could not reach by construction is now caught by an assertion over that map, validated red against a known-bad commit before it was trusted.
Stack
PHP 8 on a CMS-based multi-tenant runtime · React 18 and TypeScript · tree-sitter code index and a custom tokenizer-based DI-map generator · hosted CI with build-gating workflows.
What I’d change today
Not the container, and not for want of an alternative. Making the singletons lazy would retire this bug class outright: a factory that resolves on first use cannot reference something registered further down. It would also trade a failure that surfaces on first boot for one that only appears when execution happens to reach that path, which means trusting end-to-end coverage to reach every path before trusting a change. Eager registration constructs everything at boot, so a broken binding is loud and immediate. I would keep that trade, and keep the order check, which exists precisely because eager resolution is a decision rather than an accident.