Skip to content

feat(di): DI004 — transient IDisposable service-located from the root provider#65

Merged
PhysShell merged 2 commits into
mainfrom
claude/di004-explicit-root
Jun 21, 2026
Merged

feat(di): DI004 — transient IDisposable service-located from the root provider#65
PhysShell merged 2 commits into
mainfrom
claude/di004-explicit-root

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

The explicit / service-locator form of the transient-disposable leak

DI001/2/3 read the registration graph. DI004 reads what the graph cannot see: a resolution call site. A singleton that injects an IServiceProvider and resolves a transient IDisposable from it by hand — the service-locator anti-pattern — leaks.

public sealed class ConnectionResolver
{
    private readonly IServiceProvider _sp;
    public ConnectionResolver(IServiceProvider sp) { _sp = sp; }
    public void Warm() { var c = _sp.GetRequiredService<PooledConnection>(); }   // DI004
}
services.AddSingleton<ConnectionResolver>();
services.AddTransient<PooledConnection>();   // PooledConnection : IDisposable

For a singleton the injected provider is the root container; the root tracks every IDisposable it resolves and frees them only at application shutdown — so each call accumulates a transient that its transient registration says should be short-lived (the classic "transient disposables captured by the root container" leak), made worse by being a repeated runtime resolution. A warning, like DI003.

Filed as a distinct code (not "DI003, the explicit form"): different detection (a call site, not a constructor edge), different fix (resolve from an IServiceScope), one-code-per-rule keeps the SARIF catalogue honest.

How

  • Extractor (still syntactic): records per class the names that refer to an injected IServiceProvider — ctor params of that type plus fields assigned from them — then reads each name.GetService<T>() / GetRequiredService<T>() whose receiver is one of those names into a new root_resolves fact. A scope.ServiceProvider.Get... receiver is deliberately excluded.
  • Core (ownlang/di.py): find_explicit_root_resolutions flags a singleton whose root_resolves reaches a transient ∧ disposable service → DI004 (warning). ownir load() validates root_resolves as an array of strings (like deps/weak_deps).

Precision (0 FP) — three guards, each pinned by a silent control

class shape verdict
ConnectionResolver singleton resolves transient IDisposable off the injected root DI004
ScopedResolver resolves from a scope it creates (scope.ServiceProvider) — the correct fix silent
PlainResolver resolves a non-disposable transient (UnitOfWork) — root doesn't track it silent
RequestResolver a scoped service — its injected provider is the request scope, not the root silent

Aliases through locals, unknown receivers, and the non-generic GetService(Type) form are not guessed — silent (recall left on the table to keep precision absolute).

Pinned in CI

DiCaptiveSample.cs adds the four classes; the wpf-extractor job asserts exactly 1 DI004 (ConnectionResolver → PooledConnection) and that the three controls stay silent. The existing DI001=4 / DI002=3 / DI003=1 counts are unchanged (the new classes inject only IServiceProvider, so they add no graph edges).

Validated locally: ruff + mypy --strict clean, ownir 103/103 (the full-sample bridge reproduces DI001=4/DI002=3/DI003=1/DI004=1 with the controls silent).

Docs: P-006 + the di-captive-extractor note (DI004 shipped; naming rationale; the deferred call-site anchoring / plural-resolution slices).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added DI004 to detect singleton “service-locator” usage: when an injected root IServiceProvider directly resolves a transient IDisposable, the extractor now reports a warning with the relevant resolution path.
  • Documentation

    • Updated DI004 guidance, examples, and clarified coverage/guardrails (including supported resolution call patterns and scope-related non-leaking cases).
  • Tests

    • Expanded DI004 scenarios (including transitive cases) and added schema validation for the new detection inputs.
  • Chores

    • Adjusted CI assertions to include DI004 in the rendered results summary.

… provider

The explicit / service-locator form of the transient-disposable leak — the call site
the registration graph (DI001/2/3) cannot see. A singleton that injects an
IServiceProvider (the root container) and resolves a transient IDisposable from it by
hand (GetService<T>() / GetRequiredService<T>()) accumulates instances the root tracks
and disposes only at application shutdown: an unbounded, repeated-at-runtime leak.

Extractor: records per class the injected-IServiceProvider names (ctor params of that
type plus fields assigned from them) and reads each resolution off them into a new
`root_resolves` fact; a `scope.ServiceProvider.Get...` receiver is deliberately excluded.
Core: find_explicit_root_resolutions flags a singleton whose root_resolves reaches a
transient ∧ disposable service, surfaced as a warning (DI004) — a distinct code, not
"DI003 explicit" (different detection, different fix: resolve from an IServiceScope).

Precision (0 FP) is held by three guards, each pinned by a silent control in
DiCaptiveSample.cs: singleton-only (RequestResolver, scoped, silent), the injected
provider never a scope's (ScopedResolver, silent), transient ∧ disposable (PlainResolver
resolving non-disposable UnitOfWork, silent); ConnectionResolver is the single flagged
case. ownir load() validates root_resolves as an array of strings.

Pinned end-to-end by the wpf-extractor CI job (exactly 1 DI004 + the three silent
controls; DI001=4 / DI002=3 / DI003=1 unchanged) and at the graph level by
tests/test_ownir.py (now 103/103). Docs: P-006 + di-captive-extractor note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// FLAGGED (DI004, warning) — singleton ConnectionResolver resolves the transient
// IDisposable PooledConnection by hand off its injected ROOT IServiceProvider
// (service locator), tracked to app shutdown. A call site, not a ctor edge.
services.AddSingleton<ConnectionResolver>();
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 029b7e16-2412-43a1-a0a5-3b382c21426d

📥 Commits

Reviewing files that changed from the base of the PR and between 60315a4 and d0fc206.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • docs/notes/di-captive-extractor.md
  • docs/proposals/P-006-di-lifetimes.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/DiCaptiveSample.cs
  • ownlang/di.py
  • tests/test_ownir.py
✅ Files skipped from review due to trivial changes (1)
  • docs/notes/di-captive-extractor.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/test_ownir.py
  • ownlang/di.py
  • docs/proposals/P-006-di-lifetimes.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

📝 Walkthrough

Walkthrough

This PR implements DI004 end-to-end: a new diagnostic that detects singletons resolving transient IDisposable services directly from an injected root IServiceProvider (service-locator pattern). The Roslyn extractor gains call-site analysis to populate root_resolves; the Python core adds the ExplicitRootResolution analyzer; ownir.py bridges and emits DI004 findings; C# samples and unit tests cover both triggered and silent cases; CI assertions validate the full pipeline.

Changes

DI004 Service-Locator Detection

Layer / File(s) Summary
DI004 core model and analyzer
ownlang/di.py
Service gains a root_resolves: tuple[str, ...] field; ExplicitRootResolution dataclass and find_explicit_root_resolutions() analyzer are added to scan singletons and flag those whose recorded root resolutions reach a transient disposable service.
Roslyn extractor: root_resolves detection
frontend/roslyn/OwnSharp.Extractor/Program.cs
ExtractServices builds a per-class classRootResolves map by identifying injected IServiceProvider names (ctor parameters and assigned fields), scanning GetService<T>/GetRequiredService<T> call sites via three new helpers (AssignedFieldName, RootResolvedTypes, ReceiverIsProvider), and emits the root_resolves array on each registration fact.
C# sample fixtures
frontend/roslyn/samples/DiCaptiveSample.cs
Adds ConnectionResolver, ExprBodiedResolver, WrapperResolver with MidConnection (DI004 triggers), plus ScopedResolver, PlainResolver, and RequestResolver (all silent); includes matching Startup.ConfigureServices registrations and stand-in IServiceScope/ServiceProviderExtensions types.
OwnIR bridge wiring
ownlang/ownir.py
Imports find_explicit_root_resolutions, validates root_resolves as a JSON string array during load(), threads the field into Service(...) construction inside _di_findings(), and emits DI004 warning Finding objects mapped to their registration sites.
Unit tests and CI assertions
tests/test_ownir.py, .github/workflows/ci.yml
test_ownir.py adds find_explicit_root_resolutions and check_facts() tests covering detection, message content, severity, cascading guards (DI001–DI003 not fired), and schema rejection of non-array root_resolves. CI asserts exactly three DI004 findings for ConnectionResolver, ExprBodiedResolver, and WrapperResolver; silence for guard classes.
Documentation
docs/notes/di-captive-extractor.md, docs/proposals/P-006-di-lifetimes.md
Notes doc adds a comprehensive DI004 section covering call-site extraction logic, guard conditions, and coverage limits. Proposal doc extends the intro, Scope section, pipeline sketch, and open questions to incorporate DI004 as a shipped warning.

Sequence Diagram(s)

sequenceDiagram
  participant Roslyn as OwnSharp.Extractor
  participant OwnIR as ownir.py
  participant DI as di.py

  Roslyn->>OwnIR: emit registration fact with root_resolves[]
  OwnIR->>OwnIR: validate root_resolves is array (load)
  OwnIR->>DI: Service(root_resolves=(...)) via _di_findings()
  DI->>DI: find_explicit_root_resolutions(services)
  Note over DI: filter: singleton + transient + disposable
  DI-->>OwnIR: ExplicitRootResolution(singleton, resolved, file, line)
  OwnIR-->>OwnIR: emit Finding(code="DI004", severity="warning")
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#49: Introduced the Roslyn extractor services fact output and DiCaptiveSample.cs/CI assertions that this PR extends with the root_resolves field and DI004 checking.
  • PhysShell/Own.NET#56: Modified the same ownlang/di.py Service model and ownlang/ownir.py _di_findings() pipeline (DI003 slice) that this PR extends alongside.

Poem

🐇 A singleton once grabbed from root with flair,
Transient disposables—a captive snare!
Now DI004 watches every call,
root_resolves recorded, warnings for all.
The rabbit hops safe through the lifetime maze,
No leaked connections in these better days! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main feature: introducing DI004 detection for transient IDisposable services resolved via service-locator pattern from the root provider. It is concise, specific, and clearly summarizes the primary change across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/di004-explicit-root

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60315a48d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (p.Type is not null && DiTypeName(p.Type) == "IServiceProvider")
providerNames.Add(p.Identifier.Text);
foreach (var mem in cls.Members)
if (mem is ConstructorDeclarationSyntax ctorDecl && ctorDecl.Body is { } body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle expression-bodied provider assignments

When a singleton stores the injected provider with an expression-bodied constructor, e.g. public Resolver(IServiceProvider sp) => _sp = sp;, ctorDecl.Body is null here, so _sp never enters providerNames; the later _sp.GetRequiredService<PooledConnection>() is skipped and DI004 is not emitted. This is a common service-constructor shape and causes root-provider transient IDisposable leaks to be missed, so this alias collection should include expression-bodied constructors (and similar initializer forms) as well as block bodies.

Useful? React with 👍 / 👎.

Comment thread ownlang/di.py Outdated
tnode = by_name.get(t)
if tnode is None:
continue
if tnode.lifetime == TRANSIENT and tnode.disposable:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Follow transient graphs from root resolutions

When a singleton resolves a non-disposable transient wrapper from the root, e.g. _sp.GetRequiredService<Mid>() where Mid depends on transient IDisposable Pool, the root provider still constructs and tracks Pool until root disposal. This direct target check only reports when the resolved service itself is disposable, unlike DI003's transient DFS, so DI004 misses the same leak whenever the service-located type is a non-disposable transient with disposable transient dependencies.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/notes/di-captive-extractor.md`:
- Around line 151-153: The sentence describing the "singleton-only" behavior is
too broad and misleading as it implies all scoped/transient services necessarily
get a request-scope provider. Reword the explanation in the "singleton-only"
section to be specific to the RequestResolver example rather than making a
general statement about scoped/transient services. Clarify that RequestResolver
(which is scoped) specifically demonstrates silent behavior because it has its
own request-scope provider, rather than suggesting this is always the case for
all scoped/transient services, since a transient service resolved from root
could still see the root provider.

In `@docs/proposals/P-006-di-lifetimes.md`:
- Around line 115-118: In the section discussing DI004 implementation (around
the IServiceScopeFactory paragraph), clarify that IServiceScopeFactory itself is
not currently modeled in the tracking logic and is only suggested as a future
fix pattern. Revise the text to explicitly state that the current implementation
only tracks generic GetService<T>() and GetRequiredService<T>() calls on
injected IServiceProvider names while excluding scope.ServiceProvider receivers,
and that modeling IServiceScopeFactory is a future extension rather than a
current feature. The phrase "natural extension" should be reworded to make clear
this refers to future functionality.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 1010-1014: The code in the foreach loop that processes
AssignmentExpressionSyntax is adding all results from
AssignedFieldName(asg.Left) to providerNames without verifying that the
assignment target is an actual class field rather than a local variable or other
identifier. This causes non-field assignments (like constructor local aliases)
to be incorrectly included in providerNames, leading to false positives in
ReceiverIsProvider matching. Add a validation check after the AssignedFieldName
call to ensure the assignment target is an actual class field before adding it
to providerNames. You may need to verify the assignment is to a field by
checking the semantic model or syntax context to distinguish between field
assignments and local variable assignments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5192ea74-d1a3-4dd8-a8d5-01abb024fda9

📥 Commits

Reviewing files that changed from the base of the PR and between 9f7fd8f and 60315a4.

📒 Files selected for processing (8)
  • .github/workflows/ci.yml
  • docs/notes/di-captive-extractor.md
  • docs/proposals/P-006-di-lifetimes.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/DiCaptiveSample.cs
  • ownlang/di.py
  • ownlang/ownir.py
  • tests/test_ownir.py

Comment thread docs/notes/di-captive-extractor.md Outdated
Comment thread docs/proposals/P-006-di-lifetimes.md Outdated
Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
…itive DFS, field-only aliasing

Address both review bots on #65:

- Codex P2: an EXPRESSION-bodied constructor (`=> _sp = sp;`) has a null Body, so
  the provider alias was missed and DI004 dropped a real leak. Scan the whole ctor
  (block- or expression-bodied) and field initializers (the primary-ctor shape) for
  the `_field = providerParam` capture.
- Codex P2: resolving a NON-disposable transient wrapper off the root still makes the
  root build and track its disposable transient deps. find_explicit_root_resolutions
  now walks the resolved type's transient subtree exactly as DI003 does (entered at the
  call site), reporting disposables reached directly or transitively; scoped edges are
  not followed. ExplicitRootResolution carries the path.
- CodeRabbit (Major): restrict the alias capture to real class fields, so a constructor
  LOCAL alias can never enter providerNames and same-name-match an unrelated receiver
  (no DI004 false positive).
- CodeRabbit (docs): narrow the "singleton-only" wording to the RequestResolver sample;
  clarify a directly-injected IServiceScopeFactory is a future extension, not modelled.

Sample gains ExprBodiedResolver (expression-bodied ctor) and the transitive
WrapperResolver -> MidConnection -> PooledConnection (primary-ctor field initializer);
CI now asserts exactly 3 DI004 + the rendered transitive path; the three controls stay
silent. ruff + mypy --strict clean, ownir 104/104, full-sample bridge reproduces
DI001=4 / DI002=3 / DI003=1 / DI004=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
services.AddScoped<RequestResolver>();

// FLAGGED (DI004) — an expression-bodied ctor stores the injected root provider; same leak.
services.AddSingleton<ExprBodiedResolver>();
// FLAGGED (DI004, transitive) — service-locates the non-disposable transient MidConnection
// off the root, which drags in the transient IDisposable PooledConnection (tracked to app exit).
services.AddTransient<MidConnection>();
services.AddSingleton<WrapperResolver>();
@PhysShell
PhysShell merged commit fd3d94e into main Jun 21, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants