Skip to content

feat(di): DI002 — scoped service captured weakly by a singleton (WeakReference is not a fix)#63

Merged
PhysShell merged 3 commits into
mainfrom
claude/di002-weak-captive
Jun 21, 2026
Merged

feat(di): DI002 — scoped service captured weakly by a singleton (WeakReference is not a fix)#63
PhysShell merged 3 commits into
mainfrom
claude/di002-weak-captive

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

The unique theme: DI lifetime contracts

A new DI lifetime diagnostic, squarely in the differentiation zone — no general-purpose analyzer (CodeQL, Infer#) models DI container lifetime contracts, so this is a class only Own.NET catches.

DI002 — a singleton that holds a scoped service via WeakReference<T>. The weak reference is the usual "fix" for a DI001 captive (it stops the singleton pinning the scoped instance for the GC). But it does not fix the lifetime contract: the scoped service is still resolved from the root provider and lives for the application lifetime — the weak ref only hides the GC-retention symptom (and may go dead under the consumer). "Your fix isn't a fix." A warning, distinct from the strong DI001 capture.

public sealed class WeakCache { public WeakCache(WeakReference<AppDbContext> db) { } }  // AppDbContext is scoped
services.AddScoped<AppDbContext>();
services.AddSingleton<WeakCache>();   // DI002 — still a captive, just weakly held

How it's built

  • Extractor: a WeakReference<X> ctor parameter (WeakRefInner) is read into a separate weak_deps list, deliberately kept off the DI001 strong graph — so the same scoped service is either a strong captive (DI001) or a weak captive (DI002), never both.
  • Core (ownlang/di.py): find_weak_captive_dependencies flags a singleton whose weak_deps names a scoped service (the common direct WeakReference<Scoped> shape; the transitive weak-through-transient form is a noted future slice).
  • Bridge (ownir.py): parses weak_deps, emits DI002 at severity="warning".

Pinned in CI

DiCaptiveSample.cs, asserted in the wpf-extractor job:

registration verdict
WeakCache → WeakReference<AppDbContext> (scoped) DI002 (warning)
WeakClockHolder → WeakReference<Clock> (singleton) silent (weak ref to a singleton is no mismatch)

Exactly 1 DI002; the existing exactly-4-DI001 and exactly-1-DI003 counts are unchanged (the weak edge is off the strong graph, so WeakCache is not a 5th DI001).

Validation

Local: ruff + mypy clean, ownir 96/96 (new DI002 unit + bridge tests), and the full DI002 bridge — WeakCache → DI002 warning, not double-flagged as DI001, WeakClockHolder silent. The extractor source→facts step is validated in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added DI002 as a warning for singleton services that capture a scoped dependency via WeakReference<T>, including nullable and the transitive variant.
  • Documentation

    • Updated DI lifetime guidance to clarify DI002 vs DI001 and how weak-captured dependencies are represented/handled.
  • Tests

    • Added unit and end-to-end coverage for DI002, including schema validation that rejects invalid weak_deps.
  • CI

    • Updated CI fact checks to expect the new DI002 findings.
  • Samples

    • Extended the captive-lifetime sample with DI002 positive cases and a control case.

…Reference is not a fix)

A new DI lifetime diagnostic, squarely in the differentiation zone (no general-purpose
analyzer models DI container lifetime contracts): a singleton that holds a SCOPED service
via WeakReference<T>. The weak reference is the usual "fix" for a DI001 captive — it stops
the singleton pinning the scoped instance for the GC. But it does NOT fix the lifetime
contract: the scoped service is still resolved from the root provider and lives for the
application lifetime; the weak ref only hides the GC-retention symptom (and may go dead under
the consumer). "Your fix isn't a fix." A warning, distinct from the strong DI001 capture.

The extractor reads a `WeakReference<X>` constructor parameter (WeakRefInner) into a separate
`weak_deps` list, deliberately kept OFF the DI001 strong graph, so the same scoped service is
either a strong captive (DI001) or a weak captive (DI002), never both. ownlang/di.py
`find_weak_captive_dependencies` flags a singleton whose `weak_deps` names a scoped service
(the common direct WeakReference<Scoped> shape; the transitive weak-through-transient form is
a noted future slice). Surfaced as severity="warning" (real verdict, shown soft).

Pinned end-to-end by DiCaptiveSample.cs (WeakCache -> WeakReference<AppDbContext> flagged;
WeakClockHolder -> WeakReference<Clock> silent, a weak ref to a singleton is no mismatch) in
the wpf-extractor CI job (exactly 1 DI002, the existing exactly-4-DI001 / exactly-1-DI003
counts unchanged — the weak edge is off the strong graph), and at the graph level by
tests/test_ownir.py. Validated locally: ruff + mypy clean, ownir 96/96, the full DI002 bridge
(WeakCache DI002 warning, no double-flag as DI001, WeakClockHolder silent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
Comment thread frontend/roslyn/samples/DiCaptiveSample.cs Fixed
@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: 4bd9afd2-10e6-4e8b-a03d-0619780b8546

📥 Commits

Reviewing files that changed from the base of the PR and between f721caa and 6aaefd4.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • docs/notes/di-captive-extractor.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/DiCaptiveSample.cs
✅ 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 (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

📝 Walkthrough

Walkthrough

Adds DI002 detection for singletons that capture scoped services via WeakReference<T>. The C# Roslyn extractor now splits constructor parameters into strong deps and weak_deps lists. The Python core gains Service.weak_deps, a WeakCaptiveDependency finding type, and find_weak_captive_dependencies. The OwnIR bridge ingests weak_deps and emits DI002 warnings. Tests, CI assertions, and design docs are updated accordingly.

Changes

DI002 WeakReference captive-scope detection

Layer / File(s) Summary
Service.weak_deps data model and DI002 finding logic
ownlang/di.py
Service gains a weak_deps: tuple[str, ...] field. WeakCaptiveDependency dataclass and find_weak_captive_dependencies function are added to scan singleton weak_deps for scoped service captures and emit sorted DI002 findings with explanatory messages.
C# extractor: WeakReference<T> parsing and fact emission
frontend/roslyn/OwnSharp.Extractor/Program.cs, frontend/roslyn/samples/DiCaptiveSample.cs
Program.cs introduces a weakDeps dictionary, a WeakRefInner helper to extract inner type names from WeakReference<T> syntax, and splits constructor parameters into strong deps vs. WeakReference<T>-based weak_deps before emitting both in service facts. DiCaptiveSample.cs adds WeakCache and WeakCacheOpt (DI002 trigger cases) and WeakClockHolder (control case showing weak ref to singleton is not flagged), registered as singletons in Startup.ConfigureServices.
OwnIR bridge: weak_deps ingestion and DI002 emission
ownlang/ownir.py
Imports find_weak_captive_dependencies, validates and reads weak_deps from each service dict into Service(...) objects, and appends Finding entries for DI002 at severity="warning" inside _di_findings.
Unit tests and CI end-to-end validation
tests/test_ownir.py, .github/workflows/ci.yml
test_ownir.py adds a DI002 block asserting the correct WeakCaptiveDependency set and path, mutual exclusion with the DI001 path, message content inclusion, and a single DI002 Warning finding from the bridge. Schema validation tests confirm weak_deps must be an array-like value. CI adds assertions for exactly two WeakCache and WeakCacheOpt DI002 findings and no spurious WeakClockHolder warning.
Design docs and extractor notes
docs/proposals/P-006-di-lifetimes.md, docs/notes/di-captive-extractor.md
P-006 status updated to reflect DI001 and DI002 both firing end-to-end as warnings; DI002 scope section rewritten to describe weak_deps extraction mechanics and singleton-scoped capture detection. Extractor notes gain a full DI002 section and a revised "next slices" entry describing the transitive DI002 variant.

Sequence Diagram(s)

sequenceDiagram
  participant Extractor as C# Extractor
  participant Bridge as OwnIR Bridge
  participant Core as di.py Core
  participant Finding as Finding Report
  
  Extractor->>Extractor: Parse WeakReference constructor params
  Extractor->>Bridge: Emit service with weak_deps
  Bridge->>Core: find_weak_captive_dependencies(services)
  Core->>Core: Filter singletons capturing scoped services
  Core-->>Bridge: WeakCaptiveDependency list
  Bridge->>Finding: Emit DI002 Warning findings
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PhysShell/Own.NET#49: Established the base ExtractServices/services fact-generation flow in the Roslyn extractor and DiCaptiveSample.cs that this PR directly extends with WeakReference<T> parsing and weak_deps emission.

Poem

🐇 A singleton once held a scoped friend tight,
through WeakReference glass, a soft dimming light.
DI002 now watches, a warning in store—
"Your weak ref won't fix what the scope had before!"
The rabbit checks facts, both Python and C#,
and hops through the CI with nary a scar.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% 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 accurately describes the main change: introducing DI002 diagnostic that detects when a singleton captures a scoped service via WeakReference, directly addressing the core feature implemented across the codebase.
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/di002-weak-captive

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: 5e4a6455de

ℹ️ 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".

Comment thread ownlang/di.py Outdated
Comment thread ownlang/ownir.py

@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: 1

🧹 Nitpick comments (1)
docs/notes/di-captive-extractor.md (1)

116-116: 💤 Low value

Optional: Consider rewording to reduce reliance on "exactly".

Line 116 uses "exactly" to emphasize the differentiation point. While technically precise, a simpler phrasing might improve readability:

  • Current: "...which is exactly the differentiation."
  • Alternative: "...which is the key differentiation."

This is a minor style suggestion from static analysis; the current phrasing is defensible in a technical context.

🤖 Prompt for 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.

In `@docs/notes/di-captive-extractor.md` at line 116, In the file
docs/notes/di-captive-extractor.md, locate the phrase "which is exactly the
differentiation" and reword it to improve readability. Replace "exactly" with a
simpler alternative such as "key" to make the sentence flow better while
maintaining the technical meaning. The revised phrase should read "which is the
key differentiation" to reduce reliance on the word "exactly" while preserving
the emphasis on the differentiation point.

Source: Linters/SAST tools

🤖 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 `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 1082-1099: The WeakRefInner function doesn't handle nullable type
wrappers, causing it to miss detection of WeakReference<X>? and
WeakReference<X?> patterns. Fix this by first unwrapping any NullableTypeSyntax
wrapper from the input parameter t before applying the existing pattern matching
against GenericNameSyntax, QualifiedNameSyntax, and AliasQualifiedNameSyntax.
Additionally, when extracting the type argument from the generic type using
g.TypeArgumentList.Arguments[0], unwrap any NullableTypeSyntax wrapper from that
argument before passing it to DiTypeName to ensure nullable type arguments are
also handled correctly.

---

Nitpick comments:
In `@docs/notes/di-captive-extractor.md`:
- Line 116: In the file docs/notes/di-captive-extractor.md, locate the phrase
"which is exactly the differentiation" and reword it to improve readability.
Replace "exactly" with a simpler alternative such as "key" to make the sentence
flow better while maintaining the technical meaning. The revised phrase should
read "which is the key differentiation" to reduce reliance on the word "exactly"
while preserving the emphasis on the differentiation point.
🪄 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: 5dcc9990-b4a3-4fa6-8f7e-22d324971d05

📥 Commits

Reviewing files that changed from the base of the PR and between 8f69f46 and 5e4a645.

📒 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 frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
claude added 2 commits June 21, 2026 08:11
…odex #63)

Two Codex P2s on the DI002 PR:

1. Service.weak_deps was added BEFORE `disposable`, shifting the dataclass's positional
   constructor: `Service("Conn", "transient", (), True)` would set weak_deps=True instead
   of disposable=True (silently dropping a DI003). Current callers all pass `disposable=`
   by keyword so nothing broke, but it is a latent footgun — moved weak_deps to LAST so the
   positional contract (name, lifetime, deps, disposable, file, line) is preserved.

2. load() validated `deps` (array of strings) but not the new `weak_deps`, so a malformed
   `weak_deps` (null / scalar / dict) passed the schema and then raised a RAW TypeError in
   `python -m ownlang ownir` (or, for a string, was silently char-split by tuple()). Added
   the same array-of-strings check for weak_deps, so it now fails loudly with a controlled
   OwnIRError exactly like deps. Pinned by a load-validation test (weak_deps: "abc").

Validated: ruff + mypy clean, ownir 97/97, Service("Conn","transient",(),True) -> disposable
preserved, and `python -m ownlang ownir` rejects null/scalar/dict/string weak_deps with
OwnIRError (parity with deps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
…eRabbit #63)

CodeRabbit: WeakRefInner ignored nullable annotations, so a `WeakReference<X>?` ctor param
(and the same gap pre-existed in DiTypeName for a nullable direct dep `X? db`) was silently
dropped from BOTH the strong DI001 graph and the weak DI002 graph. A `?` annotation does not
change the injected service type, so the dep must still be seen.

Two small, general fixes: DiTypeName now unwraps NullableTypeSyntax (`X?` -> X), which covers
a nullable direct dep (DI001) and a nullable type argument (`WeakReference<X?>`); and
WeakRefInner unwraps an outer nullable wrapper (`WeakReference<X>?`) before its pattern match.

Pinned by a new WeakCacheOpt sample (`WeakReference<AppDbContext>?` -> DI002), so the
wpf-extractor step now asserts exactly 2 DI002 (WeakCache + WeakCacheOpt); the
exactly-4-DI001 / exactly-1-DI003 counts and WeakClockHolder silence are unchanged. Also
reworded a docs line ("exactly" -> "key", CodeRabbit nit). Validated: braces balanced, ownir
97/97, the 2-DI002 bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// the weak ref hides the GC-pinning symptom, but scoped AppDbContext is still
// root-resolved and app-lived (the captive lifetime violation remains). NOT a
// DI001 (the weak edge is off the strong graph).
services.AddSingleton<WeakCache>();
services.AddSingleton<WeakCache>();
// FLAGGED (DI002) — a NULLABLE WeakReference<AppDbContext>? is the same weak captive
// (the `?` annotation is unwrapped, so the scoped service is still seen).
services.AddSingleton<WeakCacheOpt>();
@PhysShell
PhysShell merged commit 6ff5f95 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