Skip to content

Commit b1ee961

Browse files
authored
Merge pull request #232 from PhysShell/claude/plans-tasks-review-u77dfs
fix(extractor): curated app-scoped source exemption for the Application subscriber (Closes #228)
2 parents d089065 + 644a88f commit b1ee961

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ jobs:
212212
frontend/roslyn/samples/UsingFieldAcquisitionSample.cs \
213213
frontend/roslyn/samples/TemplatePartLocalCaptureSample.cs \
214214
frontend/roslyn/samples/EmptyDisposeSample.cs \
215+
frontend/roslyn/samples/AppScopedSourceSample.cs \
215216
-o "$RUNNER_TEMP/facts.json"
216217
cat "$RUNNER_TEMP/facts.json"
217218
- name: Check facts through the core
@@ -849,7 +850,35 @@ jobs:
849850
# Codex P2: an empty SYNC Dispose but a real DisposeAsync (a `*Reader` here) must still leak.
850851
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ar'.*'AsyncReader'" \
851852
|| { echo "FAIL: a type with a real DisposeAsync (empty sync Dispose) must still leak (#225)"; exit 1; }
852-
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) + DI005 (scoped service cached from a created scope) + [OwnIgnore] suppression (silent-but-counted, SARIF suppressions) + #218 DP old->new subscription rotation (silent; controls flagged) + #225 empty-Dispose local exemption (silent; controls flagged) at the C# location"
853+
# issue #228 — an Application-derived subscriber on a CURATED app-scoped
854+
# resolver result (PaletteHelper.GetThemeManager) with a method-group handler
855+
# of the App class itself must be SILENT: the source is process-lived (bound
856+
# to the app's own state), so nothing is promoted. Both receiver forms — an
857+
# `is`-pattern local (App) and the direct invocation (DirectApp).
858+
if echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+:.*('App'|'DirectApp')"; then
859+
echo "FAIL: a curated app-scoped subscription inside the Application was wrongly reported (#228)"; exit 1
860+
fi
861+
# ...and the exemption must NOT over-widen — three controls STAY flagged:
862+
# (1) the SAME curated shape from a NON-Application class (the subscriber
863+
# gate stays clsIsApp, byte-for-byte);
864+
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'NotAnApp'" \
865+
|| { echo "FAIL: expected OWN001 on the non-Application subscriber (curated source alone must not exempt)"; exit 1; }
866+
# (2) App + curated source, but a LAMBDA handler capturing a local — the
867+
# exact hole that sank the rejected clsIsStatic broadening;
868+
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'LambdaApp'" \
869+
|| { echo "FAIL: expected OWN001 on the lambda-handler subscription inside the App (capture hole)"; exit 1; }
870+
# (3) App + method-group handler, but a NON-curated resolver;
871+
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'CuratedOnlyApp'" \
872+
|| { echo "FAIL: expected OWN001 on the non-curated resolver source inside the App"; exit 1; }
873+
# (4, Codex P2) the method group is qualified with ANOTHER instance of the
874+
# same App type — right ContainingType, wrong delegate target;
875+
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'TwinApp'" \
876+
|| { echo "FAIL: expected OWN001 when the handler target is another instance, not this"; exit 1; }
877+
# (5, Codex P2) the local is REASSIGNED to an injected publisher after the
878+
# curated initializer — the stale declaration binding must not exempt.
879+
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignApp'" \
880+
|| { echo "FAIL: expected OWN001 when the resolver-bound local is reassigned before the +="; exit 1; }
881+
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) + DI005 (scoped service cached from a created scope) + [OwnIgnore] suppression (silent-but-counted, SARIF suppressions) + #218 DP old->new subscription rotation (silent; controls flagged) + #225 empty-Dispose local exemption (silent; controls flagged) + #228 curated app-scoped source in App (silent; controls flagged) at the C# location"
853882
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
854883
run: |
855884
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1

‎docs/notes/oracle-known-fps.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,27 @@ which we have no reliable signal for. So those two findings stay in
308308
extractor rule. An in-code `ANTI-PATTERN` comment at the exemption site warns against
309309
re-adding `|| clsIsStatic`.
310310

311+
### What DID ship next to it (issue #228) — and why it is not the same thing
312+
313+
The #201 sweep's MaterialDesign `App.xaml.cs` FP (an `App : Application` subscribing
314+
to `PaletteHelper().GetThemeManager()`, an app-scoped instance that is process-lived
315+
without being a literal `static` member) is cleared by a narrowing that inverts every
316+
property that made `clsIsStatic` unsound:
317+
318+
- the **subscriber** gate is unchanged — byte-for-byte the existing `clsIsApp`
319+
("the subscriber IS the process-lived object", the property `clsIsStatic` lacked);
320+
- the **source** check loosens only to a **curated allowlist** of resolver methods
321+
whose result is verified app-scoped (`PaletteHelper.GetThemeManager`, verified at
322+
`PaletteHelper.cs:22-26`) — grown one confirmed sibling at a time, like the #223
323+
weak-event list, never inferred;
324+
- the **handler** must be a method group of the App class itself — a lambda is
325+
rejected outright, so the "captures a shorter-lived local and pins it" hole that
326+
sank `clsIsStatic` (and would NOT have been closed by it anyway) cannot pass.
327+
328+
Pinned by `AppScopedSourceSample.cs`: two silent positives (pattern-local and direct
329+
receiver) and three flagged controls (non-App subscriber, lambda handler, non-curated
330+
resolver), each edge of the gate.
331+
311332
## How the baseline stays honest
312333

313334
- **Matched by name, not line** — `(repo, file-basename, OWN code,

‎frontend/roslyn/OwnSharp.Extractor/Program.cs‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,120 @@ static bool IsProcessLivedApplication(TypeDeclarationSyntax cls)
895895
&& cls.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
896896
}
897897

898+
// P-004 / issue #228: the CURATED resolver allowlist whose call RESULT is app-scoped
899+
// (bound to Application-owned state, hence process-lived) without being a literal
900+
// `static` member. One entry per CONFIRMED real-world sibling — same policy as the
901+
// #223 weak-event allowlist, never an inference. Matched by (containing-type simple
902+
// name, method name), mirroring the [OwnIgnore] simple-name precedent: the declaring
903+
// package usually does not resolve on the Linux runner.
904+
// - MaterialDesign PaletteHelper.GetThemeManager(): returns the IThemeManager bound
905+
// to the app's own merged ResourceDictionary (PaletteHelper.cs:22-26, verified in
906+
// the issue #201 sweep — MahMaterialDragablzMashUp App.xaml.cs FP).
907+
static bool IsAppScopedResolver(IMethodSymbol sym) =>
908+
(sym.ContainingType?.Name, sym.Name) is ("PaletteHelper", "GetThemeManager");
909+
910+
// #228: does this `+=` receiver resolve (directly, or through a local bound by a
911+
// `var x = ...` initializer or an `is`-pattern designation) to the RESULT of a curated
912+
// app-scoped resolver call? Any unprovable step returns false — the subscription then
913+
// keeps today's honest "injected" warning, never the other way around.
914+
static bool IsCuratedAppScopedSource(ExpressionSyntax left, SemanticModel model)
915+
=> left is MemberAccessExpressionSyntax m
916+
&& ResolvesToAppScopedCall(m.Expression, model, depth: 0);
917+
918+
static bool ResolvesToAppScopedCall(ExpressionSyntax expr, SemanticModel model, int depth)
919+
{
920+
if (depth > 3)
921+
return false;
922+
expr = StripCasts(expr);
923+
if (expr is InvocationExpressionSyntax inv)
924+
{
925+
var info = model.GetSymbolInfo(inv);
926+
var sym = info.Symbol as IMethodSymbol
927+
?? info.CandidateSymbols.OfType<IMethodSymbol>().FirstOrDefault();
928+
return sym is not null && IsAppScopedResolver(sym);
929+
}
930+
if (model.GetSymbolInfo(expr).Symbol is not ILocalSymbol local)
931+
return false;
932+
// The binding is only trustworthy if the local still HOLDS it at the `+=`:
933+
// a reassignment anywhere in the enclosing member (or a ref/out escape that
934+
// could rebind it) makes the initializer stale — no exemption (Codex P2).
935+
if (!IsNeverReassigned(local, model))
936+
return false;
937+
foreach (var r in local.DeclaringSyntaxReferences)
938+
switch (r.GetSyntax())
939+
{
940+
// var tm = helper.GetThemeManager();
941+
case VariableDeclaratorSyntax { Initializer.Value: { } init }
942+
when ResolvesToAppScopedCall(init, model, depth + 1):
943+
return true;
944+
// helper.GetThemeManager() is { } tm / is ThemeManagerLike tm
945+
case SingleVariableDesignationSyntax des
946+
when des.Ancestors().OfType<IsPatternExpressionSyntax>().FirstOrDefault()
947+
is { Expression: { } scrutinee }
948+
&& ResolvesToAppScopedCall(scrutinee, model, depth + 1):
949+
return true;
950+
}
951+
return false;
952+
}
953+
954+
// #228 (Codex P2): the declaration-site binding proves the local's value at the
955+
// `+=` only if nothing rebinds it. Conservative whole-member scan: ANY assignment
956+
// whose LHS is this local, or ANY `ref`/`out` argument passing it, kills the proof
957+
// (even one after the subscription — cheaper than flow order and precision-safe:
958+
// the worst case is keeping today's honest warning).
959+
static bool IsNeverReassigned(ILocalSymbol local, SemanticModel model)
960+
{
961+
foreach (var r in local.DeclaringSyntaxReferences)
962+
{
963+
var scope = r.GetSyntax().Ancestors().FirstOrDefault(n =>
964+
n is MethodDeclarationSyntax or ConstructorDeclarationSyntax
965+
or AccessorDeclarationSyntax or LocalFunctionStatementSyntax
966+
or AnonymousFunctionExpressionSyntax);
967+
if (scope is null)
968+
return false;
969+
foreach (var asg in scope.DescendantNodes().OfType<AssignmentExpressionSyntax>())
970+
if (SymbolEqualityComparer.Default.Equals(
971+
model.GetSymbolInfo(asg.Left).Symbol, local))
972+
return false;
973+
foreach (var arg in scope.DescendantNodes().OfType<ArgumentSyntax>())
974+
if (!arg.RefOrOutKeyword.IsKind(SyntaxKind.None)
975+
&& SymbolEqualityComparer.Default.Equals(
976+
model.GetSymbolInfo(arg.Expression).Symbol, local))
977+
return false;
978+
}
979+
return true;
980+
}
981+
982+
// #228: the handler must be a METHOD GROUP declared on the subscribing class itself —
983+
// its delegate target is then the App singleton (already process-lived, nothing to
984+
// promote). A lambda/anonymous method is rejected outright: it may capture an
985+
// enclosing LOCAL, and pinning that local to the app's lifetime is exactly the leak
986+
// the rejected `clsIsStatic` broadening would have swallowed (oracle-known-fps.md,
987+
// "Rejected approaches").
988+
static bool IsOwnMethodGroupHandler(ExpressionSyntax right, SemanticModel model,
989+
INamedTypeSymbol? cls)
990+
{
991+
if (cls is null)
992+
return false;
993+
// The delegate TARGET must be `this` — a bare identifier (implicit this) or an
994+
// explicit `this.X`. A method group qualified with ANOTHER instance of the same
995+
// App-derived type (`otherApp.OnTheme`) has the right ContainingType but pins
996+
// that other instance, not the process-lived singleton (Codex P2).
997+
var receiverIsThis = right switch
998+
{
999+
IdentifierNameSyntax => true,
1000+
MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } => true,
1001+
_ => false,
1002+
};
1003+
if (!receiverIsThis)
1004+
return false;
1005+
var info = model.GetSymbolInfo(right);
1006+
var sym = info.Symbol as IMethodSymbol
1007+
?? info.CandidateSymbols.OfType<IMethodSymbol>().FirstOrDefault();
1008+
return sym is not null
1009+
&& SymbolEqualityComparer.Default.Equals(sym.ContainingType, cls);
1010+
}
1011+
8981012
// P-004 WPF MVVM ownership: a field read from `this.DataContext`, optionally through
8991013
// an `as`/cast (`DataContext as VM`, `(VM)DataContext`). Combined with a view whose
9001014
// own XAML CONSTRUCTS its DataContext, such a field is the view's owned view-model.
@@ -4063,6 +4177,19 @@ or ImplicitObjectCreationExpressionSyntax
40634177
// write-up: docs/notes/oracle-known-fps.md → "Rejected approaches".
40644178
if (!isTimer && source == "static" && clsIsApp)
40654179
continue;
4180+
// P-004 / issue #228: the same `clsIsApp` subscriber, but the source is an
4181+
// APP-SCOPED RESOLVER RESULT (curated: PaletteHelper.GetThemeManager) rather
4182+
// than a literal static member — genuinely process-lived, just reached through
4183+
// a call, so SubscriptionSourceKind honestly says "injected". This loosens
4184+
// ONLY the source check; the subscriber gate stays byte-for-byte `clsIsApp`,
4185+
// and the handler must be a METHOD GROUP of the App class itself — a lambda
4186+
// is rejected because it may capture an enclosing local, the exact hole that
4187+
// sank the `clsIsStatic` broadening above (see that comment + the "Rejected
4188+
// approaches" write-up; this narrowing is documented alongside it).
4189+
if (!isTimer && source == "injected" && clsIsApp
4190+
&& IsOwnMethodGroupHandler(a.Right, model, clsSymbol)
4191+
&& IsCuratedAppScopedSource(a.Left, model))
4192+
continue;
40664193
// P-004 / issue #199: a NON-RETAINING handler on a static (process-lived)
40674194
// source promotes nothing, so OWN014's premise ("the subscriber is pinned
40684195
// for the source's life") does not hold -> silent. A static METHOD handler
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// issue #228 — an Application-derived subscriber whose event source is an APP-SCOPED
2+
// RESOLVER RESULT (curated: PaletteHelper.GetThemeManager), not a literal static member.
3+
// The source is process-lived (bound to the app's own state), so the subscription
4+
// promotes nothing — but SubscriptionSourceKind honestly tiers a call-result receiver
5+
// "injected", which used to warn. Real-world shape: MaterialDesignInXamlToolkit
6+
// MahMaterialDragablzMashUp App.xaml.cs:10,22 (found by the issue #201 oracle sweep).
7+
//
8+
// The exemption is deliberately NARROW (see the rejected `clsIsStatic` broadening in
9+
// docs/notes/oracle-known-fps.md): subscriber must be the Application class itself,
10+
// the resolver must be on the curated list, and the handler must be a METHOD GROUP of
11+
// that class — three negative controls below pin each edge.
12+
using System;
13+
14+
namespace OwnSamples.AppScoped
15+
{
16+
// Stand-in for System.Windows.Application: IsProcessLivedApplication matches the
17+
// base NAME syntactically (WPF assemblies do not resolve on the Linux runner).
18+
public class Application { }
19+
20+
public class ThemeManagerLike
21+
{
22+
public event EventHandler? ThemeChanged;
23+
public void Raise() => ThemeChanged?.Invoke(this, EventArgs.Empty);
24+
}
25+
26+
// Stand-in for MaterialDesignThemes.Wpf.PaletteHelper — the curated resolver,
27+
// matched by (containing-type, method) = (PaletteHelper, GetThemeManager).
28+
public class PaletteHelper
29+
{
30+
static readonly ThemeManagerLike Manager = new();
31+
public ThemeManagerLike? GetThemeManager() => Manager;
32+
}
33+
34+
// Same shape, NON-curated name -> never exempted.
35+
public class OtherHelper
36+
{
37+
static readonly ThemeManagerLike Manager = new();
38+
public ThemeManagerLike? GetOtherService() => Manager;
39+
}
40+
41+
// POSITIVE (silent): App + curated resolver via `is`-pattern local + method-group
42+
// handler of the App class — the exact MaterialDesign shape.
43+
public class App : Application
44+
{
45+
public void OnStartup()
46+
{
47+
var helper = new PaletteHelper();
48+
if (helper.GetThemeManager() is { } themeManager)
49+
themeManager.ThemeChanged += ThemeManager_ThemeChanged; // silent (#228)
50+
}
51+
52+
void ThemeManager_ThemeChanged(object? sender, EventArgs e) { }
53+
}
54+
55+
// POSITIVE (silent): the direct-invocation receiver form, no intermediate local.
56+
public class DirectApp : Application
57+
{
58+
public void OnStartup()
59+
{
60+
new PaletteHelper().GetThemeManager()!.ThemeChanged += OnTheme; // silent (#228)
61+
}
62+
63+
void OnTheme(object? sender, EventArgs e) { }
64+
}
65+
66+
// CONTROL 1 (flagged): the SAME curated shape from a class that is NOT the
67+
// Application — the subscriber gate must stay `clsIsApp`, byte-for-byte.
68+
public class NotAnApp
69+
{
70+
public void Wire()
71+
{
72+
var helper = new PaletteHelper();
73+
if (helper.GetThemeManager() is { } themeManager)
74+
themeManager.ThemeChanged += OnTheme; // OWN001 warning
75+
}
76+
77+
void OnTheme(object? sender, EventArgs e) { }
78+
}
79+
80+
// CONTROL 2 (flagged): App + curated source, but the handler is a LAMBDA capturing
81+
// an enclosing local — pinning that local to app lifetime is the exact hole that
82+
// sank the `clsIsStatic` broadening; a lambda must never pass the handler gate.
83+
public class LambdaApp : Application
84+
{
85+
public void OnStartup()
86+
{
87+
var counter = new int[1];
88+
if (new PaletteHelper().GetThemeManager() is { } themeManager)
89+
themeManager.ThemeChanged += (s, e) => counter[0]++; // OWN001 warning
90+
}
91+
}
92+
93+
// CONTROL 3 (flagged): App + method-group handler, but a NON-curated resolver —
94+
// membership is a curated allowlist, not "any call result inside App".
95+
public class CuratedOnlyApp : Application
96+
{
97+
public void OnStartup()
98+
{
99+
if (new OtherHelper().GetOtherService() is { } service)
100+
service.ThemeChanged += OnTheme; // OWN001 warning
101+
}
102+
103+
void OnTheme(object? sender, EventArgs e) { }
104+
}
105+
106+
// CONTROL 4 (flagged, Codex P2): the method group is qualified with ANOTHER
107+
// instance of the same App-derived type — right ContainingType, wrong delegate
108+
// TARGET: the process-lived source pins `_twin`, not `this`.
109+
public class TwinApp : Application
110+
{
111+
readonly TwinApp? _twin;
112+
113+
public TwinApp(TwinApp? twin) => _twin = twin;
114+
115+
public void OnStartup()
116+
{
117+
if (new PaletteHelper().GetThemeManager() is { } themeManager)
118+
themeManager.ThemeChanged += _twin!.OnTheme; // OWN001 warning
119+
}
120+
121+
public void OnTheme(object? sender, EventArgs e) { }
122+
}
123+
124+
// CONTROL 5 (flagged, Codex P2): the local STARTS as the curated resolver result
125+
// but is REASSIGNED to an injected publisher before the `+=` — the declaration-site
126+
// binding is stale, so the exemption must not trust it.
127+
public class ReassignApp : Application
128+
{
129+
readonly ThemeManagerLike _injectedBus;
130+
131+
public ReassignApp(ThemeManagerLike bus) => _injectedBus = bus;
132+
133+
public void OnStartup()
134+
{
135+
var tm = new PaletteHelper().GetThemeManager()!;
136+
tm = _injectedBus;
137+
tm.ThemeChanged += OnTheme; // OWN001 warning
138+
}
139+
140+
void OnTheme(object? sender, EventArgs e) { }
141+
}
142+
}

0 commit comments

Comments
 (0)