Skip to content

feat(extractor): DI registration graph → DI001 captive dependency on real C# (P-006)#49

Merged
PhysShell merged 2 commits into
mainfrom
claude/di-captive-extractor
Jun 20, 2026
Merged

feat(extractor): DI registration graph → DI001 captive dependency on real C# (P-006)#49
PhysShell merged 2 commits into
mainfrom
claude/di-captive-extractor

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 20, 2026

Copy link
Copy Markdown
Owner

What

Gives the built-but-dormant DI001 captive-dependency check (ownlang/di.py) a C# front end. The Roslyn extractor now builds the registration + constructor graph from real code, so DI001 fires end-to-end on C# with zero core changes — a whole new diagnostic class purely by producing the services facts the lifetime core already consumes (P-006, ROADMAP milestone 3).

The bug: a singleton depending — directly, transitively through a transient, or through an interface registration — on a scoped service captures that scoped instance for the app lifetime (the classic ASP.NET Core "Cannot consume scoped service from singleton"; a DbContext held by a singleton is the canonical case).

How (extractor, Program.cs)

A syntactic pass (no SemanticModel — narrow-frontend spirit):

  • constructor graph — each class's widest constructor's parameter types;
  • registrationsAdd{Singleton,Scoped,Transient} in the generic <TService[, TImpl]> form and the typeof(...) form;
  • emits one services fact per registration: {name = service type, lifetime, deps = impl ctor params, file, line@registration}.
  • Factory / reflection / open-generic shapes we can't read → unknown-dep nodes (deps: []): silent, never guessed (P-006 non-goals).

Validation

  • Bridge-side, locally — the emitted facts → core → exactly the 3 expected DI001s, clean ones silent.
  • End-to-end, in CI (wpf-extractor job over DiCaptiveSample.cs) — the C# the extractor cannot be compiled locally, so this is the real test, and it's green:
DiCaptiveSample.cs:40: [DI001] singleton 'EmailSender' captures scoped service 'AppDbContext'  (EmailSender -> AppDbContext)
DiCaptiveSample.cs:45: [DI001] singleton 'ReportService' captures scoped service 'AppDbContext' (ReportService -> UnitOfWork -> AppDbContext)
DiCaptiveSample.cs:48: [DI001] singleton 'CacheService' captures scoped service 'IRepo'         (CacheService -> IRepo)

The assertions pin: direct + transitive-through-transient + interface-registration captures, exactly 3 findings, and silence on Metrics -> Clock (singleton→singleton) and the clean leaves. The services graph also flows through the own-check-surface + own-check-codescan jobs (DI001 now rides into the dog-food SARIF too).

Why it matters

The captive dependency is an ASP.NET-specific lifetime contract general-purpose analyzers don't model — the same complementary story as the subscription-leak class the cross-tool oracle documented. And it's a clean reuse win: a new diagnostic on real C# with no core changes.

Files

  • frontend/roslyn/OwnSharp.Extractor/Program.csExtractServices() + the services fact.
  • frontend/roslyn/samples/DiCaptiveSample.cs — the CI sample (3 flagged, 2 silent).
  • .github/workflows/ci.ymlwpf-extractor wiring + DI001 assertions.
  • docs/ — P-006 status, ROADMAP milestone 3 + indices, docs/notes/di-captive-extractor.md.

Next (separate slices): DI002 (weak-ref), DI003 (transient-IDisposable-from-root).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added end-to-end DI001 captive-dependency detection by extracting DI registrations and constructor dependencies from real C# code, and validating expected findings on the new DI captive sample.
  • Documentation

    • Added/expanded guidance on how DI001 works, including supported registration patterns and expected behavior for capturing vs. silent cases.
  • CI/Validation

    • Updated CI checks to assert the exact DI001 findings and ensure non-captive registrations are not incorrectly flagged.

…(P-006)

The DI001 captive-dependency check has lived in the core (ownlang/di.py),
validated only on hand-written `services` facts. Give it a C# front end: a
syntactic pass over the parsed trees builds the registration + constructor graph
and emits the `services` facts the core already consumes, so DI001 fires
end-to-end on real C# with ZERO core changes.

Extractor (frontend/roslyn/OwnSharp.Extractor/Program.cs):
  - ExtractServices(): a constructor-graph pass (each class's widest ctor's
    parameter types) + a registration pass over conventional IServiceCollection
    calls — Add{Singleton,Scoped,Transient} in the generic `<TService[, TImpl]>`
    form and the `typeof(...)` form. Each registration emits one service fact
    {name=service type, lifetime, deps=impl ctor params, file, line@registration}.
  - Factory / reflection / open-generic shapes we cannot read become unknown-dep
    nodes (deps: []) — silent, never guessed (P-006 non-goals).
  - `services` is added to the emitted facts (empty when a scan has no
    registrations).

Sample + CI (frontend/roslyn/samples/DiCaptiveSample.cs, wpf-extractor job):
  asserts a direct capture (singleton EmailSender -> scoped AppDbContext), a
  transitive one through a transient (ReportService -> UnitOfWork -> AppDbContext),
  an interface-registration one (AddScoped<IRepo, Repo>; CacheService -> IRepo),
  exactly three findings, and silence on singleton->singleton (Metrics -> Clock)
  and the clean leaves.

Validated bridge-side locally (the emitted facts -> core -> the 3 expected DI001s,
clean ones silent); the C#->facts step is validated in CI (no local .NET SDK).

docs: P-006 status advanced (extractor built, end-to-end), ROADMAP milestone 3 +
indices, and docs/notes/di-captive-extractor.md.

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

// FLAGGED — singleton captures a scoped service directly.
services.AddSingleton<EmailSender>();
Comment thread frontend/roslyn/samples/DiCaptiveSample.cs Fixed
@coderabbitai

coderabbitai Bot commented Jun 20, 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: 2a73ee19-7cf1-4f3b-a16a-544b0a20cda2

📥 Commits

Reviewing files that changed from the base of the PR and between e4495cb and 215132d.

📒 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

The PR implements DI001 captive-dependency detection end-to-end: a new C# sample file defines flagged and silent DI registrations; the Roslyn extractor gains an ExtractServices pass that builds a constructor graph and emits services facts; the CI workflow validates exactly four DI001 findings; and ROADMAP, notes, and P-006 proposal documents are updated accordingly.

Changes

DI001 Captive Dependency End-to-End

Layer / File(s) Summary
DiCaptiveSample and ExtractServices implementation
frontend/roslyn/samples/DiCaptiveSample.cs, frontend/roslyn/OwnSharp.Extractor/Program.cs
DiCaptiveSample.cs introduces flagged singleton→scoped (direct, transitive, interface-mapped, primary-constructor) and silent registrations with stand-in DI types. Program.cs adds ExtractServices() that collects constructor parameter types per class and scans Add{Singleton,Scoped,Transient} generic and typeof(...) call patterns to emit a services JSON list; the facts output is extended with the new services field.
CI DI001 assertions
.github/workflows/ci.yml
DiCaptiveSample.cs is added to the extractor command line; new verification steps assert DI001 presence, validate four specific captive-path messages, confirm non-captive registrations are silent, enforce exactly four DI001 findings from the file, and update the final OK message.
ROADMAP, notes, and P-006 proposal updates
docs/notes/di-captive-extractor.md, docs/proposals/P-006-di-lifetimes.md, docs/ROADMAP.md
di-captive-extractor.md is introduced with the extractor design, services fact schema, CI validation expectations, and DI002/DI003 follow-ons. P-006-di-lifetimes.md and ROADMAP.md are updated to reflect DI001 end-to-end completion.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#11: Wires the Roslyn extractor's services facts output into the core DI001 checking pipeline that produces the findings verified by this PR's CI assertions.

Poem

🐇 Hop hop, a singleton tried,
To hold a scoped friend by its side —
The extractor said "Nay!" with facts to display,
Four findings found, no place to hide! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 change: implementing DI registration graph extraction for the DI001 captive dependency diagnostic on real C# code.
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/di-captive-extractor

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: e4495cb693

ℹ️ 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 +741 to +742
foreach (var m in cls.Members)
if (m is ConstructorDeclarationSyntax ctor

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 Read primary constructors for service deps

When a registered service uses C# 12 primary-constructor injection, such as public sealed class EmailSender(AppDbContext db), there is no ConstructorDeclarationSyntax member; the parameters live on the class declaration itself. This loop leaves widest null and emits an empty dependency list, so DI001 misses singleton-to-scoped captures in current .NET 8/ASP.NET-style code.

Useful? React with 👍 / 👎.

Comment on lines +742 to +746
if (m is ConstructorDeclarationSyntax ctor
&& (widest is null
|| ctor.ParameterList.Parameters.Count
> widest.ParameterList.Parameters.Count))
widest = ctor;

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 Exclude non-public constructors from DI deps

When a service has a wider private/protected constructor that takes a scoped dependency plus a public constructor that DI can actually use, this code selects the non-public constructor and emits those parameters as real dependencies. The default IServiceProvider only uses public constructors, so DI001 can report a captive dependency at the registration site even though that scoped service is never consumed by DI.

Useful? React with 👍 / 👎.

…x review)

Two real ctor-selection gaps in ExtractServices that the classic-ctor sample
did not exercise:

- Primary constructors (C# 12). `class EmailSender(AppDbContext db)` has no
  ConstructorDeclarationSyntax member — its parameters live on the class
  declaration. The loop left `widest` null and emitted empty deps, so DI001 missed
  captures in modern .NET 8 / ASP.NET code. Seed `widest` from `cls.ParameterList`
  (the primary ctor).
- Non-public constructors. The default IServiceProvider resolves through PUBLIC
  ctors only; a wider private/protected ctor taking a scoped dependency must not
  count, or DI001 reports a captive DI never creates. Gate explicit ctors on
  IsPublicCtor.

Sample + CI pin both: a primary-ctor singleton -> scoped capture is flagged
(PrimaryCtorService -> AppDbContext); a service whose only public ctor is
parameterless but which also has a wider private ctor with a scoped dep stays
silent (PublicCtorOnly). Count is now exactly four captives. Re-validated
bridge-side; the C#->facts step is validated in CI.

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

// FLAGGED — through the interface registration: singleton -> IRepo (scoped).
services.AddSingleton<CacheService>();
services.AddSingleton<CacheService>();

// FLAGGED — primary-constructor injection: singleton -> scoped AppDbContext.
services.AddSingleton<PrimaryCtorService>();
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