Split out of the original bundled roadmap line (see #438). The binding half is now #468 and
is closed; this issue is the diagnostics half only, and the report its needs-info label was
waiting on is below.
The report
validate()'s scope check follows redirects to decide whether an edge is inverted
(terminal_scope in dependency_graph.py), then builds a message naming the declared dependency
beside the terminal's scope, and discards everything in between. Through an Alias — which is how
an abstract base or Protocol is bound to an implementation — those are two different providers, and
the one you have to change is named nowhere.
Handler at APP depending on Impl at REQUEST through a three-hop Protocol chain:
Container.validate() found 1 issue(s): InvalidScopeDependencyError
InvalidScopeDependencyError (1):
- Provider Handler (scope APP) declares parameter 'svc' typed as a provider of IA at deeper scope REQUEST. A provider cannot depend on a deeper-scoped provider.
IA is the Protocol and declares no scope at all. REQUEST belongs to Impl. Impl does not
appear, and it is not recoverable in code either: .dep_provider is the alias and its source type is
private, so the fact is absent from the message and from the attribute surface.
Resolving the same graph, rather than validating it, prints this:
Cannot resolve dependency chain:
APP Handler (app:8)
APP └─> IA
APP └─> IB
APP └─> IC
REQUEST └─> Impl (app:7)
caused by: Provider of scope REQUEST cannot be resolved in container of scope APP.
Why this is not "the wording could be better"
Per ADR 0009,
a message that reads awkwardly is not a defect. Three things here are more than that:
- A sibling error out of the same walk already does it right. A cycle through the same alias
renders the full arrow tree with declaration sites, because build_cycle_error keeps the
providers it walked. Two errors from one validate() call, one drawing the chain and one printing
a line, is the drift the shared _render_chain exists to prevent.
- The fact is missing from the attributes, not just the text. That is ADR 0009's own revisit
trigger, one step earlier: there is nothing to parse out of str(exc) because the terminal is not
in the string.
validate(), whose only job is diagnosis, prints strictly less than the crash it exists to
prevent. Even with no alias involved this error carries no module:line for either provider,
while every other chain-shaped error does.
Also note the runtime tree above draws APP for IA, IB and IC. Alias sets
_takes_group_scope = False and passes scope=UNSET, so AbstractProvider.scope returns the
Scope.APP default for every alias — a band nothing in the graph chose. CircularDependencyError
prints the same fiction today.
Resolution
Implemented on fix/scope-violation-chain, recorded as ADR 0029:
- the redirect walk keeps its chain (
terminal_chain()), rather than discarding it to return a scope;
InvalidScopeDependencyError renders through _render_chain like its three siblings, and gains
.dep_chain with .dep_provider / .dep_terminal as its ends;
- each redirect hop draws at the scope it resolves at, so the APP/REQUEST boundary lands on the
offending edge. Alias.scope still returns APP; scope ordering depends on it, so this is a
rendering rule, not a change to the provider.
The same graph now renders identically from both detectors:
InvalidScopeDependencyError (1):
- Provider at a deeper scope reached through this chain:
APP Handler (app:8)
REQUEST └─> IA
REQUEST └─> IB
REQUEST └─> IC
REQUEST └─> Impl (app:7)
caused by: Handler (scope APP) declares parameter 'svc' typed as a provider of Impl at deeper scope REQUEST. A provider cannot depend on a deeper-scoped provider.
Guarded by an invariant test that validate() and a runtime resolve name the same chain, an
attribute test that the terminal is recoverable without parsing the message, and a rendering test
that the alias hop draws REQUEST while its own .scope is APP.
Split out of the original bundled roadmap line (see #438). The binding half is now #468 and
is closed; this issue is the diagnostics half only, and the report its
needs-infolabel waswaiting on is below.
The report
validate()'s scope check follows redirects to decide whether an edge is inverted(
terminal_scopeindependency_graph.py), then builds a message naming the declared dependencybeside the terminal's scope, and discards everything in between. Through an
Alias— which is howan abstract base or
Protocolis bound to an implementation — those are two different providers, andthe one you have to change is named nowhere.
HandleratAPPdepending onImplatREQUESTthrough a three-hopProtocolchain:IAis the Protocol and declares no scope at all.REQUESTbelongs toImpl.Impldoes notappear, and it is not recoverable in code either:
.dep_provideris the alias and its source type isprivate, so the fact is absent from the message and from the attribute surface.
Resolving the same graph, rather than validating it, prints this:
Why this is not "the wording could be better"
Per ADR 0009,
a message that reads awkwardly is not a defect. Three things here are more than that:
renders the full arrow tree with declaration sites, because
build_cycle_errorkeeps theproviders it walked. Two errors from one
validate()call, one drawing the chain and one printinga line, is the drift the shared
_render_chainexists to prevent.trigger, one step earlier: there is nothing to parse out of
str(exc)because the terminal is notin the string.
validate(), whose only job is diagnosis, prints strictly less than the crash it exists toprevent. Even with no alias involved this error carries no
module:linefor either provider,while every other chain-shaped error does.
Also note the runtime tree above draws
APPforIA,IBandIC.Aliassets_takes_group_scope = Falseand passesscope=UNSET, soAbstractProvider.scopereturns theScope.APPdefault for every alias — a band nothing in the graph chose.CircularDependencyErrorprints the same fiction today.
Resolution
Implemented on
fix/scope-violation-chain, recorded as ADR 0029:terminal_chain()), rather than discarding it to return a scope;InvalidScopeDependencyErrorrenders through_render_chainlike its three siblings, and gains.dep_chainwith.dep_provider/.dep_terminalas its ends;offending edge.
Alias.scopestill returnsAPP; scope ordering depends on it, so this is arendering rule, not a change to the provider.
The same graph now renders identically from both detectors:
Guarded by an invariant test that
validate()and a runtime resolve name the same chain, anattribute test that the terminal is recoverable without parsing the message, and a rendering test
that the alias hop draws
REQUESTwhile its own.scopeisAPP.