Skip to content

Commit aa1af42

Browse files
committed
feat(extractor): recognise Behavior.AssociatedObject as a self-owned source (#227)
A `Behavior`-derived class subscribing to (an element reached from) its own base-class `AssociatedObject` accessor was tiered "injected" and warned OWN001 (the MahApps TiltBehavior FP from the issue #201 sweep). A behavior is attached to and detached from exactly one element and cannot outlive being attached, so that element is co-lifetimed with the subscriber — the same collectable source<->this cycle the shipped self-owned-source exemption already encodes for a constructed field, just reached through a base-class accessor. The exemption is deliberately narrow, within the issue's fixed guardrails: - subscriber gate: the class must derive from `Behavior` (`IsBehaviorSubscriber`, direct-base simple-name match, mirroring `IsProcessLivedApplication`) — the attach/detach pairing is what guarantees co-lifetime; - source gate: the `+=` receiver must resolve, same-class and assignment-chain- local, to `this.AssociatedObject` (`ResolvesToAssociatedObject`) — directly, via a `var x = ...`/`is`-pattern local (only when never reassigned), or via a field every assignment to which resolves to `AssociatedObject` (a single injected write denies the proof). No interprocedural guessing. Unlike #228 a lambda handler is fine here: the source is co-lifetimed with the behavior, so a capture just closes the collectable cycle rather than pinning a process-lived source to a shorter-lived local. Pinned by AssociatedObjectSourceSample.cs: three silent positives (is-pattern field, direct `this.AssociatedObject.Event`, bare-identifier local) and the four controls the guardrails require — the unrelated injected source in the same OnAttached, a non-Behavior subscriber, a field also assigned an injected value, and a resolver-bound local reassigned before the `+=` — wired into the wpf-extractor CI job with assertions both ways. Verified locally with the real extractor (.NET 8): the sample yields exactly the four control warnings, all three positives silent; a full-sample-set diff of old vs new extractor output is byte-identical (zero regression). Gates: run_tests 276/276, ruff, mypy, yaml all green. Closes #227 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2AWb9N2VUsby2XpdftBcu
1 parent b1ee961 commit aa1af42

4 files changed

Lines changed: 326 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ jobs:
213213
frontend/roslyn/samples/TemplatePartLocalCaptureSample.cs \
214214
frontend/roslyn/samples/EmptyDisposeSample.cs \
215215
frontend/roslyn/samples/AppScopedSourceSample.cs \
216+
frontend/roslyn/samples/AssociatedObjectSourceSample.cs \
216217
-o "$RUNNER_TEMP/facts.json"
217218
cat "$RUNNER_TEMP/facts.json"
218219
- name: Check facts through the core
@@ -878,7 +879,37 @@ jobs:
878879
# curated initializer — the stale declaration binding must not exempt.
879880
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignApp'" \
880881
|| { 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"
882+
# issue #227 — a `Behavior`-derived subscriber whose event source is (an element
883+
# reached from) its own base-class `AssociatedObject` must be SILENT: the
884+
# behavior cannot outlive being attached, so the source is co-lifetimed with the
885+
# subscriber (a collectable self-cycle, not a leak). Three receiver forms:
886+
# an `is`-pattern local off a field assigned from AssociatedObject (TiltLikeBehavior),
887+
# the direct `this.AssociatedObject.Event` (DirectAssociatedBehavior), and a
888+
# bare-identifier local bound from AssociatedObject (LocalAssociatedBehavior).
889+
if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*'panel\.Loaded'.*'TiltLikeBehavior'"; then
890+
echo "FAIL: the AssociatedObject-derived subscription in the Behavior was wrongly reported (#227)"; exit 1
891+
fi
892+
if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*('DirectAssociatedBehavior'|'LocalAssociatedBehavior')"; then
893+
echo "FAIL: a direct/local AssociatedObject subscription in the Behavior was wrongly reported (#227)"; exit 1
894+
fi
895+
# ...and the required negative control: an UNRELATED injected source subscribed
896+
# in the SAME OnAttached stays flagged.
897+
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'_bus\.Changed'.*'TiltLikeBehavior'" \
898+
|| { echo "FAIL: expected OWN001 on the unrelated injected source in the same OnAttached (#227)"; exit 1; }
899+
# ...and the exemption must NOT over-widen — three controls STAY flagged:
900+
# (1) the same AssociatedObject shape from a NON-Behavior subscriber (the gate
901+
# is the `Behavior` base, not the member name);
902+
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'NotABehavior'" \
903+
|| { echo "FAIL: expected OWN001 on the non-Behavior subscriber (AssociatedObject name alone must not exempt)"; exit 1; }
904+
# (2) a field assigned from AssociatedObject AND from an injected value elsewhere
905+
# — every assignment must resolve to AssociatedObject;
906+
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'MixedFieldBehavior'" \
907+
|| { echo "FAIL: expected OWN001 when the field is also assigned an injected value (#227)"; exit 1; }
908+
# (3) the local starts as AssociatedObject but is REASSIGNED to an injected
909+
# source before the `+=` — the stale declaration binding must not exempt.
910+
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignedLocalBehavior'" \
911+
|| { echo "FAIL: expected OWN001 when the AssociatedObject-bound local is reassigned before the +="; exit 1; }
912+
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) + #227 self-owned Behavior.AssociatedObject source (silent; controls flagged) at the C# location"
882913
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
883914
run: |
884915
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1

docs/notes/field-notes-patterns.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,13 @@ exemption's real criterion is "does this object's lifetime start and end with th
532532
subscriber's" — a base-class accessor to the attached object, or an item of an
533533
owned collection, satisfies that just as well as a constructed field.**
534534

535+
**Status (2026-07):** shape **(a)**, the `Behavior.AssociatedObject` self-owned
536+
source, shipped in #227 — extractor `IsAssociatedObjectSource`, gated on the
537+
`Behavior` base (`IsBehaviorSubscriber`) plus a same-class assignment-chain
538+
resolving to `this.AssociatedObject` (`ResolvesToAssociatedObject`); pinned by
539+
`frontend/roslyn/samples/AssociatedObjectSourceSample.cs`. Shape **(c)**, the
540+
owned-collection element, is tracked separately by #229.
541+
535542
## 16. Template part fetched via `FindName`/`GetTemplateChild`, stored as a local
536543

537544
**Seen in:** MahApps.Metro `src/MahApps.Metro/Controls/MetroWindow.cs:1447-1449`

frontend/roslyn/OwnSharp.Extractor/Program.cs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,132 @@ static bool IsOwnMethodGroupHandler(ExpressionSyntax right, SemanticModel model,
10091009
&& SymbolEqualityComparer.Default.Equals(sym.ContainingType, cls);
10101010
}
10111011

1012+
// P-004 / issue #227: is the subscriber a `Behavior`-derived class? A behavior is
1013+
// attached to (and detached from) exactly one element and CANNOT outlive being
1014+
// attached, so the element it reaches through the base-class `AssociatedObject`
1015+
// accessor is co-lifetimed with the behavior — the same collectable source<->this
1016+
// cycle the shipped self-owned-source exemption already encodes for a constructed
1017+
// field. Matched SYNTACTICALLY by the direct base's simple name `Behavior`
1018+
// (mirroring IsProcessLivedApplication): the Microsoft.Xaml.Behaviors /
1019+
// System.Windows.Interactivity `Behavior`/`Behavior<T>` base does not resolve on the
1020+
// Linux runner. Only the direct base is inspected — an intermediate user base
1021+
// (`class Concrete : MyBehavior`) is not chased (precision-first: no exemption, the
1022+
// honest warning stands), exactly as the App-partial precedent does.
1023+
static bool IsBehaviorSubscriber(TypeDeclarationSyntax cls)
1024+
{
1025+
if (cls.BaseList is not { } bl)
1026+
return false;
1027+
foreach (var bt in bl.Types)
1028+
if (SimpleBaseName(bt.Type) == "Behavior")
1029+
return true;
1030+
return false;
1031+
}
1032+
1033+
static string? SimpleBaseName(TypeSyntax t) => t switch
1034+
{
1035+
IdentifierNameSyntax id => id.Identifier.Text,
1036+
GenericNameSyntax g => g.Identifier.Text, // Behavior<T>
1037+
QualifiedNameSyntax q => SimpleBaseName(q.Right), // ...Interactivity.Behavior
1038+
AliasQualifiedNameSyntax aq => SimpleBaseName(aq.Name),
1039+
_ => null,
1040+
};
1041+
1042+
// #227: a bare/`this`-qualified read of the base-class `AssociatedObject` accessor —
1043+
// `this.AssociatedObject` or `AssociatedObject`. Matched by NAME (the property is
1044+
// inherited from the unresolved `Behavior` base), through casts/`!`. A member access
1045+
// qualified with anything else (`other.AssociatedObject`) reaches ANOTHER object and
1046+
// is not this behavior's own attached element, so it is rejected.
1047+
static bool IsAssociatedObjectAccess(ExpressionSyntax expr)
1048+
{
1049+
expr = StripCasts(expr);
1050+
return expr switch
1051+
{
1052+
MemberAccessExpressionSyntax m => m.Name.Identifier.Text == "AssociatedObject"
1053+
&& m.Expression is ThisExpressionSyntax,
1054+
IdentifierNameSyntax id => id.Identifier.Text == "AssociatedObject",
1055+
_ => false,
1056+
};
1057+
}
1058+
1059+
// #227: does `expr` provably resolve to `this.AssociatedObject` — directly, or through
1060+
// an assignment-chain-LOCAL step (a `var x = ...` initializer, an `is`-pattern
1061+
// designation, or a FIELD of this class assigned from it)? The provenance is
1062+
// syntactic and same-class (never interprocedural): a local carries the binding only
1063+
// when nothing rebinds it (IsNeverReassigned), and a field only when EVERY assignment
1064+
// to it in the class resolves to `AssociatedObject` (a single injected/constructed
1065+
// write anywhere denies the proof — precision-first, the worst case keeps the honest
1066+
// warning). Depth-bounded so a self-referential field cannot spin.
1067+
static bool ResolvesToAssociatedObject(ExpressionSyntax expr, SemanticModel model,
1068+
TypeDeclarationSyntax clsNode, int depth)
1069+
{
1070+
if (depth > 4)
1071+
return false;
1072+
expr = StripCasts(expr);
1073+
if (IsAssociatedObjectAccess(expr))
1074+
return true;
1075+
var sym = model.GetSymbolInfo(expr).Symbol;
1076+
if (sym is ILocalSymbol local)
1077+
{
1078+
// The declaration-site binding proves the local's value at the use only if
1079+
// nothing rebinds it (same conservative whole-member scan as #228).
1080+
if (!IsNeverReassigned(local, model))
1081+
return false;
1082+
foreach (var r in local.DeclaringSyntaxReferences)
1083+
switch (r.GetSyntax())
1084+
{
1085+
// var panel = this.AssociatedObject;
1086+
case VariableDeclaratorSyntax { Initializer.Value: { } init }
1087+
when ResolvesToAssociatedObject(init, model, clsNode, depth + 1):
1088+
return true;
1089+
// this.AssociatedObject is Panel panel / ... is { } panel
1090+
case SingleVariableDesignationSyntax des
1091+
when des.Ancestors().OfType<IsPatternExpressionSyntax>().FirstOrDefault()
1092+
is { Expression: { } scrutinee }
1093+
&& ResolvesToAssociatedObject(scrutinee, model, clsNode, depth + 1):
1094+
return true;
1095+
}
1096+
return false;
1097+
}
1098+
if (sym is IFieldSymbol field)
1099+
return FieldAssignedOnlyFromAssociatedObject(field, model, clsNode, depth);
1100+
return false;
1101+
}
1102+
1103+
// #227: a field is a valid `AssociatedObject` alias only when it is populated ONLY
1104+
// from `AssociatedObject` — at least one such assignment, and no assignment to a
1105+
// value we cannot prove is `AssociatedObject` (an injected/constructed write would
1106+
// make the field's contents ambiguous at the `+=`). Class-level assignment scan, the
1107+
// same mechanism the field-based self-owned search already uses; the field-population
1108+
// evidence may live in the same `OnAttached` or any other member of the class.
1109+
static bool FieldAssignedOnlyFromAssociatedObject(IFieldSymbol field, SemanticModel model,
1110+
TypeDeclarationSyntax clsNode, int depth)
1111+
{
1112+
var any = false;
1113+
foreach (var asg in clsNode.DescendantNodes().OfType<AssignmentExpressionSyntax>())
1114+
{
1115+
if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression))
1116+
continue;
1117+
if (!SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(asg.Left).Symbol, field))
1118+
continue;
1119+
any = true;
1120+
if (!ResolvesToAssociatedObject(asg.Right, model, clsNode, depth + 1))
1121+
return false;
1122+
}
1123+
return any;
1124+
}
1125+
1126+
// #227: the self-owned-source exemption for a `Behavior` reaching its own
1127+
// `AssociatedObject`. The `+=` receiver must resolve to `this.AssociatedObject` (or an
1128+
// assignment-chain-local/field/pattern-var provably drawn from it). Caller gates on
1129+
// IsBehaviorSubscriber — attaching/detaching guarantees co-lifetime ONLY in that
1130+
// pairing — so a lambda handler is fine here (unlike #228): capturing `this`/its
1131+
// locals just closes the collectable source<->behavior cycle, it does not pin a
1132+
// process-lived source to a shorter-lived capture.
1133+
static bool IsAssociatedObjectSource(ExpressionSyntax left, SemanticModel model,
1134+
TypeDeclarationSyntax clsNode)
1135+
=> left is MemberAccessExpressionSyntax m
1136+
&& ResolvesToAssociatedObject(m.Expression, model, clsNode, depth: 0);
1137+
10121138
// P-004 WPF MVVM ownership: a field read from `this.DataContext`, optionally through
10131139
// an `as`/cast (`DataContext as VM`, `(VM)DataContext`). Combined with a view whose
10141140
// own XAML CONSTRUCTS its DataContext, such a field is the view's owned view-model.
@@ -4108,6 +4234,11 @@ or ImplicitObjectCreationExpressionSyntax
41084234
// static-source region escape (OWN014) — `App` cannot be over-promoted.
41094235
var clsIsApp = IsProcessLivedApplication(cls);
41104236

4237+
// #227: is this a `Behavior`-derived subscriber? Then a `+=` whose source is
4238+
// its own `AssociatedObject` (the attached element, co-lifetimed with the
4239+
// behavior) is the collectable self-owned cycle, not a leak.
4240+
var clsIsBehavior = IsBehaviorSubscriber(cls);
4241+
41114242
var subs = new List<object>();
41124243
foreach (var a in assigns)
41134244
{
@@ -4140,6 +4271,18 @@ or ImplicitObjectCreationExpressionSyntax
41404271
|| (IsProcessLifetimeAppDomainEvent(ev)
41414272
&& HandlerRetainsNoInstance(a.Right, model))))
41424273
continue;
4274+
// P-004 / issue #227: a `Behavior` subscribing to (an element reached
4275+
// from) its own `AssociatedObject`. The behavior cannot outlive being
4276+
// attached, so the source is co-lifetimed with the subscriber — the
4277+
// same self-owned source<->this cycle as a constructed field, just
4278+
// reached through the base-class accessor. Gated on the `Behavior`
4279+
// base (co-lifetime holds ONLY in the attach/detach pairing) and on a
4280+
// same-class assignment-chain provenance to `AssociatedObject`; a
4281+
// subscription to an unrelated injected/constructed source in the same
4282+
// method keeps today's warning (its receiver does not resolve there).
4283+
if (!isTimer && clsIsBehavior
4284+
&& IsAssociatedObjectSource(a.Left, model, cls))
4285+
continue;
41434286
// P-004 (issue #223): the curated weak-referenced-static-event allowlist —
41444287
// unconditional (unlike the AppDomain exemption above, this does NOT gate on
41454288
// HandlerRetainsNoInstance: the whole point of a weak-referenced source is

0 commit comments

Comments
 (0)