Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,18 @@ jobs:
# is unwrapped, so the scoped service is still seen (CodeRabbit review on #63).
echo "$out" | grep -qE "\[DI002\].*'WeakCacheOpt' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI002 on the nullable WeakReference (WeakCacheOpt)"; exit 1; }
# transitive DI002: a singleton weakly holds the transient UnitOfWork, which strongly
# drags in scoped AppDbContext (WeakReport -> UnitOfWork -> AppDbContext). The weak DFS
# follows the transient's strong edges like DI001 does.
echo "$out" | grep -qE "\[DI002\].*'WeakReport' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected transitive DI002 (WeakReport -> UnitOfWork -> AppDbContext)"; exit 1; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# pin the rendered transitive PATH (not just the finding), so a path-rendering
# regression fails CI (CodeRabbit review on #64).
echo "$out" | grep -q "WeakReport -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive DI002 path text"; exit 1; }
nwk=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI002\]")
[ "$nwk" = "2" ] \
|| { echo "FAIL: expected exactly 2 DI002 findings, got $nwk"; exit 1; }
[ "$nwk" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI002 findings, got $nwk"; exit 1; }
if echo "$out" | grep -q "WeakClockHolder"; then
echo "FAIL: a weak ref to a singleton (WeakClockHolder) was wrongly flagged"; exit 1
fi
Expand Down
20 changes: 10 additions & 10 deletions docs/notes/di-captive-extractor.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,18 @@ fix."* A **warning** (`severity="warning"` — real, shown soft), distinct from
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. Pinned end-to-end by `DiCaptiveSample.cs` (`WeakCache` →
`WeakReference<AppDbContext>`, with `WeakClockHolder → WeakReference<Clock>` staying silent —
a weak ref to a singleton is no mismatch) in the `wpf-extractor` CI job, and at the graph
level by `tests/test_ownir.py`. It is a contract no general-purpose analyzer models — even the
developer's WeakReference "fix" is still flagged, which is the key differentiation.
`ownlang/di.py` `find_weak_captive_dependencies` flags a singleton that *reaches* a scoped
service from a weak dep — directly (`WeakReference<Scoped>`) or **transitively** through a
weakly-held transient that strongly drags in the scoped, the same strong-edge DFS DI001 runs
but rooted at the weak edge. Pinned end-to-end by `DiCaptiveSample.cs` — `WeakCache` /
`WeakCacheOpt` (`WeakReference<AppDbContext>`, the second nullable), and `WeakReport`
(`WeakReference<UnitOfWork>` → scoped `AppDbContext`, the transitive case); `WeakClockHolder →
WeakReference<Clock>` stays silent (a weak ref to a singleton is no mismatch) — in the
`wpf-extractor` CI job, and at the graph level by `tests/test_ownir.py`. It is a contract no
general-purpose analyzer models — even the developer's WeakReference "fix" is still flagged,
which is the key differentiation.

## Next (separate slices)

- **DI002, the transitive form** — a singleton holding a `WeakReference<Transient>` whose
transient *drags in* a scoped service (the weak edge is one hop above the scoped); the
shipped slice flags the common **direct** `WeakReference<Scoped>` shape.
- **DI003, the explicit form** — a transient `IDisposable` resolved by hand from the
**root** provider (`root.GetService<T>()`), which the graph form above does not see (it
needs the resolution call sites, not just the registration graph).
Expand Down
8 changes: 8 additions & 0 deletions frontend/roslyn/samples/DiCaptiveSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
// a NULLABLE weak reference (`WeakReference<AppDbContext>?`) is the same weak captive — the
// `?` annotation does not change the service type, so it is DI002 too (CodeRabbit review).
public sealed class WeakCacheOpt { public WeakCacheOpt(WeakReference<AppDbContext>? db) { } }
// DI002 transitive — a singleton weakly holds a TRANSIENT (UnitOfWork) that strongly
// depends on a scoped service (AppDbContext). The weak edge enters the transient, but the
// scoped it drags in is still root-resolved and app-lived: DI002 (path WeakReport ->
// UnitOfWork -> AppDbContext). NOT a DI001 (the entry edge is weak).
public sealed class WeakReport { public WeakReport(WeakReference<UnitOfWork> uow) { } }
// control: a weak reference to a SINGLETON is no lifetime mismatch -> SILENT.
public sealed class WeakClockHolder { public WeakClockHolder(WeakReference<Clock> clock) { } }

Expand All @@ -70,7 +75,7 @@
services.AddScoped<IRepo, Repo>(); // interface -> impl, scoped

// FLAGGED — singleton captures a scoped service directly.
services.AddSingleton<EmailSender>();

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [resource: DI lifetime]

Check warning on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext)

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext)

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [resource: DI lifetime]

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [resource: DI lifetime]

Check warning on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext)

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext)

Check failure on line 78 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [resource: DI lifetime]

services.AddTransient<UnitOfWork>(); // transient

Expand All @@ -78,10 +83,10 @@
services.AddSingleton<ReportService>();

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

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo) [resource: DI lifetime]

Check warning on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo)

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo)

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo) [resource: DI lifetime]

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo) [resource: DI lifetime]

Check warning on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo)

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo)

Check failure on line 86 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'CacheService' captures scoped service 'IRepo' (captive dependency: CacheService -> IRepo) [resource: DI lifetime]

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

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext) [resource: DI lifetime]

Check warning on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext)

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext)

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext) [resource: DI lifetime]

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext) [resource: DI lifetime]

Check warning on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext)

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext)

Check failure on line 89 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI001

[DI001] singleton 'PrimaryCtorService' captures scoped service 'AppDbContext' (captive dependency: PrimaryCtorService -> AppDbContext) [resource: DI lifetime]

// SILENT — DI resolves the public parameterless ctor; the private ctor's
// scoped dependency is never used (no false captive).
Expand All @@ -94,16 +99,19 @@
// transient IDisposable PooledConnection: promoted to application lifetime,
// disposed only at root disposal. NOT a DI001 (no scoped captured).
services.AddTransient<PooledConnection>();
services.AddSingleton<ConnectionWarmer>();

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection)

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection)

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI003

[DI003] singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection) [resource: DI lifetime]

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection)

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection)

Check warning on line 102 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI003

[DI003] singleton 'ConnectionWarmer' captures transient IDisposable 'PooledConnection': it is promoted to application lifetime and disposed only when the root provider is disposed (ConnectionWarmer -> PooledConnection) [resource: DI lifetime]

// FLAGGED (DI002, warning) — singleton holds a SCOPED service via WeakReference:
// 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>();

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext)

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext)

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI002

[DI002] singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext) [resource: DI lifetime]

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext)

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext)

Check warning on line 108 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI002

[DI002] singleton 'WeakCache' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCache -> AppDbContext) [resource: DI lifetime]
// 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>();

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext)

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext)

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI002

[DI002] singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext) [resource: DI lifetime]

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext)

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext)

Check warning on line 111 in frontend/roslyn/samples/DiCaptiveSample.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

DI002

[DI002] singleton 'WeakCacheOpt' weakly captures scoped service 'AppDbContext' (WeakReference): 'AppDbContext' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (WeakCacheOpt -> AppDbContext) [resource: DI lifetime]
// FLAGGED (DI002, transitive) — singleton weakly holds the transient UnitOfWork,
// which strongly drags in scoped AppDbContext (WeakReport -> UnitOfWork -> AppDbContext).
services.AddSingleton<WeakReport>();
// SILENT — a weak reference to the SINGLETON Clock is no lifetime mismatch.
services.AddSingleton<WeakClockHolder>();
}
Expand Down
42 changes: 28 additions & 14 deletions ownlang/di.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,41 @@ def message(self) -> str:

def find_weak_captive_dependencies(
services: list[Service]) -> list[WeakCaptiveDependency]:
"""Return every scoped service a singleton holds via `WeakReference<T>` (DI002).
The direct form: a singleton whose `weak_deps` names a scoped service. The weak
reference keeps it off the DI001 strong-capture graph, but the scoped instance is
still root-resolved and app-lived — a lifetime-contract violation, surfaced as a
warning. (Weakly-held transients that *drag in* a scoped are a separate, rarer
slice — not followed here, the direct weak-scoped edge is the common 'I wrapped my
captive in WeakReference' shape.)"""
"""Return every scoped service a singleton reaches via `WeakReference<T>` (DI002).
From each weak dependency, walk the STRONG dependency chain exactly as DI001 does: the
weak edge enters a service the singleton holds weakly, and a scoped service it reaches —
directly (`WeakReference<Scoped>`) or transitively through a weakly-held transient that
strongly depends on it — is still root-resolved and app-lived, a lifetime-contract
violation surfaced as a warning. Transients are followed (a transient resolved through
the singleton drags its scoped dep along); a singleton edge is another singleton's own
pass. Cycles are guarded. The weak entry edge keeps it off the DI001 strong graph."""
by_name = {s.name: s for s in services}
findings: list[WeakCaptiveDependency] = []
for s in services:
if s.lifetime != SINGLETON:
continue
reported: set[str] = set()
for dep in s.weak_deps:
dnode = by_name.get(dep)
if dnode is None or dnode.lifetime != SCOPED or dep in reported:
visited: set[str] = set()
# DFS rooted at the WEAK deps, then following STRONG transient edges (DI001-style).
stack: list[tuple[str, tuple[str, ...]]] = [
(dep, (s.name, dep)) for dep in s.weak_deps]
while stack:
cur, path = stack.pop()
cnode = by_name.get(cur)
if cnode is None:
continue
reported.add(dep)
findings.append(WeakCaptiveDependency(
singleton=s.name, captured=dep, path=(s.name, dep),
file=s.file, line=s.line))
if cnode.lifetime == SCOPED:
if cur not in reported:
reported.add(cur)
findings.append(WeakCaptiveDependency(
singleton=s.name, captured=cur, path=path,
file=s.file, line=s.line))
continue # the violating scoped edge is found; don't recurse past it
if cnode.lifetime == TRANSIENT and cur not in visited:
visited.add(cur)
for d in cnode.deps: # follow the transient's STRONG deps
stack.append((d, (*path, d)))
# a singleton edge is safe here (the inner singleton is reported on its own pass)
findings.sort(key=lambda f: (f.file, f.line, f.singleton, f.captured))
return findings

Expand Down
13 changes: 10 additions & 3 deletions tests/test_ownir.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,18 +530,25 @@ def _sub(source: str | None) -> list[Finding]:
# graph; a weak ref to a singleton is no mismatch, so it stays silent.
from ownlang.di import find_weak_captive_dependencies
wsvcs = [
Service("WeakCache", "singleton", deps=(), weak_deps=("Db",)), # weak -> scoped: DI002
Service("WeakCache", "singleton", deps=(), weak_deps=("Db",)), # weak->scoped: DI002
Service("Db", "scoped", ()),
Service("Strong", "singleton", deps=("Db",)), # strong -> scoped: DI001
Service("Strong", "singleton", deps=("Db",)), # strong->scoped: DI001
Service("WeakReport", "singleton", deps=(), weak_deps=("Uow",)), # weak->transient->scoped
Service("Uow", "transient", deps=("Db",)),
Service("WeakClock", "singleton", deps=(), weak_deps=("Clk",)), # weak -> singleton: safe
Service("Clk", "singleton", ()),
]
di2 = find_weak_captive_dependencies(wsvcs)
checks += 1
got2 = sorted((c.singleton, c.captured) for c in di2)
if got2 != [("WeakCache", "Db")]:
if got2 != [("WeakCache", "Db"), ("WeakReport", "Db")]:
fails.append(f"DI002 set wrong: {got2}")
checks += 1
# the transitive weak captive carries the full path through the weakly-held transient.
wpath = next((c.path for c in di2 if c.singleton == "WeakReport"), None)
if wpath != ("WeakReport", "Uow", "Db"):
fails.append(f"DI002 transitive path wrong: {wpath}")
checks += 1
# the weak captive must NOT also be a strong DI001 (weak edge is off the strong graph).
if any(c.singleton == "WeakCache" for c in find_captive_dependencies(wsvcs)):
fails.append("DI002 weak captive wrongly also flagged as DI001")
Expand Down
Loading