@@ -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
0 commit comments