Skip to content

feat(di): DI003 — transient IDisposable captured by a singleton (new DI lifetime warning)#56

Merged
PhysShell merged 3 commits into
mainfrom
claude/di003-transient-disposable
Jun 21, 2026
Merged

feat(di): DI003 — transient IDisposable captured by a singleton (new DI lifetime warning)#56
PhysShell merged 3 commits into
mainfrom
claude/di003-transient-disposable

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

A new DI lifetime diagnostic — DI003: a transient IDisposable captured by a singleton. The container resolves the disposable once (from the root, via the singleton) and holds it for the whole app — disposed only at root disposal, far longer than its transient registration implies. A class no general-purpose analyzer models (the ASP.NET-lifetime niche, same complementary story as DI001).

It reuses the DI001 registration-graph DFS almost verbatim — target = transient ∧ disposable instead of scoped — so it's mostly core-side (ownlang/di.py), which means it's validated end-to-end locally, not blind-in-CI.

Surfaced as a warning (severity="warning" — a real verdict shown soft; the framework allows it, the lifetime promotion is the smell), not advisory.

How

  • ownlang/di.pyService gains a disposable flag; new find_captured_transient_disposables (the DI001 DFS, retargeted).
  • ownlang/ownir.py_di_findings parses disposable and emits DI003 findings at Severity "warning".
  • extractor (ExtractServices) — emits disposable per service: the impl's own base list names IDisposable/IAsyncDisposable (syntactic — an inherited disposable is not guessed, so DI003 fires only where ownership is certain).
  • DiCaptiveSample.cs — a DI003 case (singleton ConnectionWarmer captures transient IDisposable PooledConnection); ci.yml asserts it end-to-end and that it does not inflate the DI001 captive count.

Precision, by construction

disposable is a new fact field (defaults false), so existing facts can't fire DI003 — zero regression. DiCaptiveSample's existing transient (UnitOfWork) is not IDisposable, so the 4 DI001 captives are unchanged (the CI count check proves it). DI003 only fires when an impl is syntactically : IDisposable.

Validation

  • Local, end-to-end: di.py + the bridge via check_facts (direct, transitive-through-transient, non-disposable-silent, scoped-not-flagged); test_ownir 91/91 (added DI003 unit + bridge tests); full suite green (wpf 4/4, lifetimes 10/10, spec 22/22).
  • CI: the extractor's disposable emission + the sample are validated by the wpf-extractor job (real C# → facts → [DI003] warning at the registration site).

Files

  • ownlang/di.py, ownlang/ownir.py — the check + bridge.
  • frontend/roslyn/OwnSharp.Extractor/Program.csdisposable emission.
  • frontend/roslyn/samples/DiCaptiveSample.cs, .github/workflows/ci.yml — the CI case + assertion.
  • tests/test_ownir.py — DI003 unit + bridge pins.
  • docs/ — P-006, ROADMAP, di-captive-extractor.md (DI003 → shipped; next: DI002, the explicit root.GetService<T>() form).

This is a new diagnostic class, not a corpus row (DI is graph-based, not .own-expressible), so the corpus recall number is unchanged by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added a DI003 diagnostic warning to flag transient IDisposable services captured by singleton services, including end-to-end CI validation.
    • Enhanced the DI extraction logic to identify which implementations are disposable and propagate this into OwnIR findings.
  • Documentation

    • Updated the DI lifetimes proposal, roadmap status, and captive-dependency notes to reflect DI003 behavior.
  • Tests

    • Added unit and OwnIR bridge coverage for DI003, and extended the DI captive sample scenario to exercise DI003.
  • CI

    • Updated the CI “OK” checks to explicitly include the new DI003 verification.

…lifetime warning)

A transient IDisposable captured by a singleton is resolved from the root (via the
singleton), promoted to application lifetime, and disposed only at root disposal --
held far longer than its `transient` registration implies. A new DI lifetime
diagnostic (warning), a class no general-purpose analyzer models -- the ASP.NET niche.

Reuses the DI001 registration-graph DFS almost verbatim (ownlang/di.py
find_captured_transient_disposables, target = transient AND disposable instead of
scoped). Surfaced as a warning (severity="warning", a real verdict shown soft -- the
framework allows it; the lifetime promotion is the smell), not advisory.

- ownlang/di.py: Service gains `disposable`; new find_captured_transient_disposables.
- ownlang/ownir.py: _di_findings parses `disposable` and emits DI003 (warning).
- extractor (ExtractServices): emits `disposable` per service -- the impl's OWN base
  list names IDisposable/IAsyncDisposable (syntactic; an inherited disposable is not
  guessed, so DI003 fires only where ownership is certain).
- DiCaptiveSample.cs: a DI003 case (singleton ConnectionWarmer captures transient
  IDisposable PooledConnection); ci.yml asserts it end-to-end (and that it does NOT
  inflate the DI001 captive count).

Precision by construction: `disposable` is a NEW fact field (defaults false), so
existing facts can't fire DI003 -- zero regression; DiCaptiveSample's existing
transient (UnitOfWork) is not IDisposable, so the 4 DI001 captives are unchanged.

Validated locally: di.py + bridge via check_facts; test_ownir 91/91. The extractor
`disposable` emission + the sample are validated by the wpf-extractor CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// transient IDisposable PooledConnection: promoted to application lifetime,
// disposed only at root disposal. NOT a DI001 (no scoped captured).
services.AddTransient<PooledConnection>();
services.AddSingleton<ConnectionWarmer>();
@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: 235ea0a0-1bef-4144-92d1-278335886737

📥 Commits

Reviewing files that changed from the base of the PR and between 682e937 and 3b0dd6c.

📒 Files selected for processing (3)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • ownlang/ownir.py
  • tests/test_ownir.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • ownlang/ownir.py
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • tests/test_ownir.py

📝 Walkthrough

Walkthrough

Implements the DI003 diagnostic end-to-end. The Roslyn extractor gains a disposable flag on emitted services facts by checking syntactic IDisposable/IAsyncDisposable base types. A new Python DFS analyzer detects transient disposable services captured by singletons, the OwnIR bridge emits DI003 warnings, unit tests validate the analyzer and bridge, and CI/docs are updated.

Changes

DI003: Transient IDisposable Captured by Singleton

Layer / File(s) Summary
C# sample types and extractor disposable tracking
frontend/roslyn/samples/DiCaptiveSample.cs, frontend/roslyn/OwnSharp.Extractor/Program.cs
Adds PooledConnection : System.IDisposable and ConnectionWarmer types to DiCaptiveSample.cs as the DI003 scenario, registers them in Startup.ConfigureServices, and extends ExtractServices to build a ctorDisposable dictionary and emit a disposable boolean on each services fact.
Service.disposable field and DI003 analyzer
ownlang/di.py
Adds a disposable: bool = False field to the Service dataclass, introduces the CapturedTransientDisposable record, and implements find_captured_transient_disposables which DFS-walks from each singleton through transient-only edges, reporting any transient dependency marked disposable.
OwnIR bridge wiring and unit tests
ownlang/ownir.py, tests/test_ownir.py
Imports find_captured_transient_disposables, passes disposable= when constructing Service objects, and appends DI003 findings with severity="warning" in _di_findings. Tests assert the analyzer returns the correct singleton→transient-disposable pairs, that messages mention IDisposable, and that check_facts emits exactly one DI003 warning.
CI assertions and documentation
.github/workflows/ci.yml, docs/ROADMAP.md, docs/notes/di-captive-extractor.md, docs/proposals/P-006-di-lifetimes.md
Extends the wpf-extractor CI job to assert exactly one DI003 finding for ConnectionWarmer/PooledConnection. Updates roadmap, extractor notes, and proposal doc to mark DI003 as shipped and reframe outstanding work as the explicit root.GetService<T>() call-site form.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#11: Both PRs modify the DI findings pipeline in ownlang/ownir.py; this PR extends _di_findings to additionally emit DI003 warnings alongside the existing DI001 captive-dependency findings.
  • PhysShell/Own.NET#49: This PR extends the same ExtractServices/services facts pipeline established in the retrieved PR by adding a disposable flag, and then uses that flag to emit new DI003 findings.

Poem

🐇 A transient disposed by the root, oh my!
The singleton grabbed it and wouldn't say bye.
I sniffed through the graph with a DFS nose,
Found IDisposable wherever it goes.
DI003 now warns with a wriggle of ears—
No more captured leaks across app-lifetime years! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.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 accurately summarizes the main change: introduction of DI003, a new DI lifetime warning for transient IDisposable captured by singletons.
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/di003-transient-disposable

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

ruff I001 (the new find_captured_transient_disposables import sat on its own line)
and E501 (DI003 test Service rows). No behaviour change; suite + mypy --strict green.

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

@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: 682e93779a

ℹ️ 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 on lines +907 to +908
ctorDisposable[cls.Identifier.Text] = cls.BaseList is { } bl
&& bl.Types.Any(bt => DiTypeName(bt.Type) is "IDisposable" or "IAsyncDisposable");

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 Preserve IDisposable across partial declarations

When scanning a directory containing partial service implementations, a later partial declaration without a base list overwrites an earlier true value here. For partial class Conn : IDisposable plus partial class Conn { ... } (common with generated/designer files), the transient registration is emitted with disposable=false, so a singleton depending on it no longer produces DI003. Aggregate this flag with any existing value instead of assigning the current declaration's base-list result.

Useful? React with 👍 / 👎.

Comment thread ownlang/ownir.py Outdated
name=str(s.get("name", "?")),
lifetime=str(s.get("lifetime", "")),
deps=tuple(s.get("deps", [])),
disposable=bool(s.get("disposable", False)),

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 Validate disposable as a boolean before coercing

If OwnIR is produced by anything other than this extractor and serializes the new field as "false" (or any non-empty string), bool(...) turns it into True; load() currently doesn't reject that type, so the CLI emits a DI003 warning and exits with findings for a service explicitly marked non-disposable. Treat only the boolean value true as true and validate/reject non-boolean disposable values.

Useful? React with 👍 / 👎.

…rcion

- Program.cs: aggregate the IDisposable flag across partial class declarations
  (any part naming IDisposable wins), so a later partial without a base list (a
  generated/designer file) can't clear an earlier `partial C : IDisposable`.
- ownir.py: parse `disposable` as `is True` (only the JSON boolean true), so a
  stray string from a non-extractor producer can't coerce to disposable=True.

Suite + ruff + mypy --strict green.

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

@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)
frontend/roslyn/OwnSharp.Extractor/Program.cs (1)

907-908: Fix is optional: partial class overwrite concern is valid but no partial classes exist in main source.

The logic at lines 907-908 unconditionally overwrites ctorDisposable[cls.Identifier.Text] per class declaration. For partial classes with mixed IDisposable / non-disposable base lists, the last part would determine the value, potentially setting disposable=false incorrectly. However, verification found zero partial classes in the main source code—all partial classes are test corpus only.

The suggested fix using prev || declaresDisposable is defensively sound and recommended for future-proofing, but not urgent. The code comment "last decl wins" already acknowledges the overwrite behavior, though it doesn't account for partial class semantics.

Suggested fix (optional)
-            ctorDisposable[cls.Identifier.Text] = cls.BaseList is { } bl
-                && bl.Types.Any(bt => DiTypeName(bt.Type) is "IDisposable" or "IAsyncDisposable");
+            var declaresDisposable = cls.BaseList is { } bl
+                && bl.Types.Any(bt => DiTypeName(bt.Type) is "IDisposable" or "IAsyncDisposable");
+            ctorDisposable[cls.Identifier.Text] =
+                ctorDisposable.TryGetValue(cls.Identifier.Text, out var prev)
+                    ? prev || declaresDisposable
+                    : declaresDisposable;
🤖 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 `@frontend/roslyn/OwnSharp.Extractor/Program.cs` around lines 907 - 908, In the
ctorDisposable dictionary assignment at line 907-908, change the unconditional
overwrite of ctorDisposable[cls.Identifier.Text] to use a preserving assignment
pattern. Instead of directly assigning the result of the IDisposable check, use
a logical OR operation to preserve true values across multiple declarations:
change the assignment to account for both the existing value (if any) and the
current class declaration's disposable status. This ensures that for partial
classes, if any declaration implements IDisposable or IAsyncDisposable, the
class remains marked as disposable rather than being overwritten to false by a
later partial class declaration without those interfaces.
🤖 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 `@ownlang/ownir.py`:
- Line 1236: The issue is in the line where `disposable=bool(s.get("disposable",
False))` uses truthiness coercion instead of strict boolean parsing. This causes
string values like "false" to evaluate to True (since non-empty strings are
truthy), leading to false-positive DI003 findings. Replace the bool() coercion
with proper boolean parsing that explicitly checks for boolean true values or
their string representations, defaulting to False when the field is absent or
has a falsy value. This ensures that string "false" or actual False values are
correctly parsed as False, not True.

---

Nitpick comments:
In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 907-908: In the ctorDisposable dictionary assignment at line
907-908, change the unconditional overwrite of
ctorDisposable[cls.Identifier.Text] to use a preserving assignment pattern.
Instead of directly assigning the result of the IDisposable check, use a logical
OR operation to preserve true values across multiple declarations: change the
assignment to account for both the existing value (if any) and the current class
declaration's disposable status. This ensures that for partial classes, if any
declaration implements IDisposable or IAsyncDisposable, the class remains marked
as disposable rather than being overwritten to false by a later partial class
declaration without those interfaces.
🪄 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: 2e83c1c2-226f-4708-b1e0-0eb942a82dbe

📥 Commits

Reviewing files that changed from the base of the PR and between b803d22 and 682e937.

📒 Files selected for processing (9)
  • .github/workflows/ci.yml
  • docs/ROADMAP.md
  • 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 ownlang/ownir.py Outdated
@PhysShell
PhysShell merged commit 53258fb into main Jun 21, 2026
22 checks passed
PhysShell pushed a commit that referenced this pull request Jun 21, 2026
…ractor

First wild hunt after the recall run. ShareX exercises the disposable / subscription
/ DI suite on unseen code beyond WPF -- a generalization + precision spot-check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
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