Skip to content

Commit 3bc9a05

Browse files
authored
Merge pull request #213 from PhysShell/claude/own-net-issue-200-scopefactory
docs(P-006): reconcile OQ#3 — directly-injected IServiceScopeFactory is modelled (Closes #200)
2 parents 6d7d875 + a3a2c33 commit 3bc9a05

3 files changed

Lines changed: 56 additions & 11 deletions

File tree

‎docs/ROADMAP.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,12 @@ architectural strictness, and the borrow-checker showcase):
218218
the scope is disposed and is promoted to app lifetime) is built end to end too, a store-site
219219
property anchored at the field assignment. The family now also has its first **real-world
220220
corpus case** — a singleton injecting a scoped EF `DbContext` → DI001 (`corpus/di/`, a
221-
benchmark-only corpus since DI has no `.own` form). Remaining (deliberate-deferral / future):
222-
directly-injected `IServiceScopeFactory`-as-a-positive-signal recognition (P-006 OQ#3), and
223-
the dynamic registrations that are explicit non-goals.
221+
benchmark-only corpus since DI has no `.own` form). Directly-injected
222+
`IServiceScopeFactory`-as-a-positive-signal recognition (P-006 OQ#3) is **done** — shipped in
223+
PR #126, reconciled in #200: the correct scope-per-operation pattern is silent *by construction*
224+
(a scope-resolved value used in the scope is a local, not a `scope_cached` field store), and
225+
caching it into a field is DI005. Remaining (deliberate non-goals): the dynamic registrations a
226+
static graph cannot see.
224227
4. **Pool/Span** — `Rent`/`Return`, borrowed views, return-invalidates-views,
225228
known-bug replay corpus (P-007). The borrow checker on stage at full height.
226229
◑ *In progress* — POOL001 (leak), POOL002 (view-after-return → OWN002),

‎docs/notes/di-captive-extractor.md‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,41 @@ hand-resolved, not at the container-built `PooledConnection`. Falls back to the
216216
when the call is unknown. Pinned by `DiCaptiveSample.cs` (`ConnectionResolver:79`,
217217
`ExprBodiedResolver:123`, transitive `WrapperResolver:137`).
218218

219+
## DI005 — scope-resolved scoped service cached into a field (shipped), and the OQ#3 fix recognised
220+
221+
The remedy DI001/DI002 point at is **scope-per-operation**: inject `IServiceScopeFactory`, and per
222+
operation `using var scope = factory.CreateScope();` then resolve the scoped dependency *inside* the
223+
scope. DI005 catches that remedy done wrong — the scope-resolved **scoped** service **cached into a
224+
field**. The field outlives the `using` scope, so the cached instance dangles after the scope (and
225+
the service) is disposed *and* is promoted to the singleton's application lifetime: the captive is
226+
back, hidden behind the API meant to fix it (a **warning**, anchored at the field-store site).
227+
228+
The extractor (still purely syntactic) records the **scope-creator** names with the same this-field
229+
discipline as DI004 — a **directly-injected `IServiceScopeFactory`** *and* an injected
230+
`IServiceProvider` (both expose `CreateScope()`) — then the scope locals their `CreateScope()`
231+
produces, and every `scope.ServiceProvider.Get(Required)Service<T>()` whose result is **assigned to
232+
a field** into a `scope_cached` list with its store site. `find_scope_cached_captives`
233+
(`ownlang/di.py`) walks each cached entry's strong transient graph like DI001; the field-store site
234+
is the finding's primary anchor, the registration the secondary.
235+
236+
**This is the answer to P-006 open question #3 — recognising the directly-injected
237+
`IServiceScopeFactory` fix.** It needs no separate "approval" fact: the correct pattern (resolve
238+
inside the scope, use, **discard** — a local, not a field store) simply **produces no `scope_cached`
239+
entry**, so it is silent **by construction**. The "positive signal" is the *absence* of a captive
240+
fact. Recognising the factory injection as licence to suppress *other* captive findings would be
241+
wrong — a singleton that also injects a scoped service directly is still DI001. Pinned end-to-end by
242+
`DiCaptiveSample.cs` (`ScopeCachingService` DI005 direct, `UnitOfWorkCachingService` DI005
243+
transitive; `ScopeUsingService` — the correct scope-per-operation use — and `ClockCachingService`
244+
— a cached *singleton*, shareable — both silent) in the `wpf-extractor` CI job, and at the graph
245+
level by `tests/test_ownir.py`.
246+
219247
## Next (separate slices)
220248
- Per-**parameter** precision for the captive anchor (the specific injecting parameter, not just
221249
the constructor).
222-
- The plural `GetServices<T>()` and non-generic `GetService(typeof(T))` resolution forms, and a
223-
directly-injected `IServiceScopeFactory` as the recognised fix (DI004 currently reads the
224-
generic singular `Get(Required)Service<T>()` and the `CreateScope()` → scope-provider form).
250+
- The plural `GetServices<T>()` and non-generic `GetService(typeof(T))` resolution forms (DI004
251+
currently reads the generic singular `Get(Required)Service<T>()`).
252+
- **A scope-resolved scoped service that *escapes* its scope by being returned (or passed out as a
253+
`ref`/`out`/method argument)** rather than cached into a field — the same lifetime promotion as
254+
DI005, but through a data-flow edge the store-site pass does not model. Silent today
255+
(precision-safe: the extractor records only field stores, so an escaping local is no
256+
`scope_cached` fact). A candidate for a future flow-aware slice, not the store-site model.

‎docs/proposals/P-006-di-lifetimes.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,21 @@ rather than guessed.
162162
2. How far to chase transitive captures through the constructor graph before the
163163
dynamic cases make it unreliable? (Bounded depth; stop at unknown edges.)
164164
3. Is `IServiceScopeFactory` usage inside a singleton recognised as the *fix*
165-
(so we stay silent), as it should be? (For the explicit form (DI004): **yes**, by
166-
construction — DI004 records only `GetService<T>()` / `GetRequiredService<T>()` on the
167-
injected `IServiceProvider` names and **excludes** a scope's `.ServiceProvider` receiver, so
168-
resolving from a scope created with `CreateScope()` is silent. Modelling a directly-injected
169-
`IServiceScopeFactory` is not yet implemented — a natural future extension.)
165+
(so we stay silent), as it should be? **Resolved — shipped in PR #126, reconciled in #200.**
166+
For the explicit form (DI004): **yes**, by construction — DI004 records only `GetService<T>()` /
167+
`GetRequiredService<T>()` on the injected `IServiceProvider` names and **excludes** a scope's
168+
`.ServiceProvider` receiver, so resolving from a scope created with `CreateScope()` is silent.
169+
A **directly-injected `IServiceScopeFactory`** is modelled the same way: the extractor
170+
recognises it as a scope-creator name (`Program.cs`, alongside the injected provider) and emits
171+
a `scope_cached` fact **only** for a value stored into a *field*. So the correct scope-per-
172+
operation pattern — resolve inside the scope, use, discard (a local) — produces **no fact** and
173+
stays silent *by construction*, while caching the scope-resolved scoped service into a field is
174+
DI005. That silence **is** the finished state: the "positive signal" is the **absence** of a
175+
captive fact, not a separate approval marker. Recognising the factory injection as licence to
176+
suppress *other* captive findings would be wrong — a singleton that also injects a scoped service
177+
directly is still DI001, regardless of any scope it opens elsewhere. Pinned by
178+
`DiCaptiveSample.cs` (`ScopeUsingService` silent / `ScopeCachingService` DI005), the
179+
`wpf-extractor` CI DI-contrast, and `tests/test_ownir.py`.
170180
4. Treat transient-`IDisposable`-from-root (DI003/DI004) as warning or error? (Warning
171181
— it is a slow leak, not always a bug. DI004's call-site form is repeated at runtime,
172182
arguably worse, but kept a warning for consistency with DI003.)

0 commit comments

Comments
 (0)