From aa1af42e7b14f550be9ef6ced061adc5bb219da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:25:13 +0000 Subject: [PATCH 1/3] feat(extractor): recognise Behavior.AssociatedObject as a self-owned source (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01U2AWb9N2VUsby2XpdftBcu --- .github/workflows/ci.yml | 33 +++- docs/notes/field-notes-patterns.md | 7 + frontend/roslyn/OwnSharp.Extractor/Program.cs | 143 +++++++++++++++++ .../samples/AssociatedObjectSourceSample.cs | 144 ++++++++++++++++++ 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 frontend/roslyn/samples/AssociatedObjectSourceSample.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6f423d..98aa7a57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,7 @@ jobs: frontend/roslyn/samples/TemplatePartLocalCaptureSample.cs \ frontend/roslyn/samples/EmptyDisposeSample.cs \ frontend/roslyn/samples/AppScopedSourceSample.cs \ + frontend/roslyn/samples/AssociatedObjectSourceSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - name: Check facts through the core @@ -878,7 +879,37 @@ jobs: # curated initializer — the stale declaration binding must not exempt. echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignApp'" \ || { echo "FAIL: expected OWN001 when the resolver-bound local is reassigned before the +="; exit 1; } - 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" + # issue #227 — a `Behavior`-derived subscriber whose event source is (an element + # reached from) its own base-class `AssociatedObject` must be SILENT: the + # behavior cannot outlive being attached, so the source is co-lifetimed with the + # subscriber (a collectable self-cycle, not a leak). Three receiver forms: + # an `is`-pattern local off a field assigned from AssociatedObject (TiltLikeBehavior), + # the direct `this.AssociatedObject.Event` (DirectAssociatedBehavior), and a + # bare-identifier local bound from AssociatedObject (LocalAssociatedBehavior). + if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*'panel\.Loaded'.*'TiltLikeBehavior'"; then + echo "FAIL: the AssociatedObject-derived subscription in the Behavior was wrongly reported (#227)"; exit 1 + fi + if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*('DirectAssociatedBehavior'|'LocalAssociatedBehavior')"; then + echo "FAIL: a direct/local AssociatedObject subscription in the Behavior was wrongly reported (#227)"; exit 1 + fi + # ...and the required negative control: an UNRELATED injected source subscribed + # in the SAME OnAttached stays flagged. + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'_bus\.Changed'.*'TiltLikeBehavior'" \ + || { echo "FAIL: expected OWN001 on the unrelated injected source in the same OnAttached (#227)"; exit 1; } + # ...and the exemption must NOT over-widen — three controls STAY flagged: + # (1) the same AssociatedObject shape from a NON-Behavior subscriber (the gate + # is the `Behavior` base, not the member name); + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'NotABehavior'" \ + || { echo "FAIL: expected OWN001 on the non-Behavior subscriber (AssociatedObject name alone must not exempt)"; exit 1; } + # (2) a field assigned from AssociatedObject AND from an injected value elsewhere + # — every assignment must resolve to AssociatedObject; + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'MixedFieldBehavior'" \ + || { echo "FAIL: expected OWN001 when the field is also assigned an injected value (#227)"; exit 1; } + # (3) the local starts as AssociatedObject but is REASSIGNED to an injected + # source before the `+=` — the stale declaration binding must not exempt. + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignedLocalBehavior'" \ + || { echo "FAIL: expected OWN001 when the AssociatedObject-bound local is reassigned before the +="; exit 1; } + 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" - name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2) run: | # Path-sensitive flow analysis of local IDisposables — bugs the flat D1 diff --git a/docs/notes/field-notes-patterns.md b/docs/notes/field-notes-patterns.md index 30ede01f..82df1147 100644 --- a/docs/notes/field-notes-patterns.md +++ b/docs/notes/field-notes-patterns.md @@ -532,6 +532,13 @@ exemption's real criterion is "does this object's lifetime start and end with th subscriber's" — a base-class accessor to the attached object, or an item of an owned collection, satisfies that just as well as a constructed field.** +**Status (2026-07):** shape **(a)**, the `Behavior.AssociatedObject` self-owned +source, shipped in #227 — extractor `IsAssociatedObjectSource`, gated on the +`Behavior` base (`IsBehaviorSubscriber`) plus a same-class assignment-chain +resolving to `this.AssociatedObject` (`ResolvesToAssociatedObject`); pinned by +`frontend/roslyn/samples/AssociatedObjectSourceSample.cs`. Shape **(c)**, the +owned-collection element, is tracked separately by #229. + ## 16. Template part fetched via `FindName`/`GetTemplateChild`, stored as a local **Seen in:** MahApps.Metro `src/MahApps.Metro/Controls/MetroWindow.cs:1447-1449` diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 56ab8281..584c00fc 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1009,6 +1009,132 @@ static bool IsOwnMethodGroupHandler(ExpressionSyntax right, SemanticModel model, && SymbolEqualityComparer.Default.Equals(sym.ContainingType, cls); } +// P-004 / issue #227: is the subscriber a `Behavior`-derived class? A behavior is +// attached to (and detached from) exactly one element and CANNOT outlive being +// attached, so the element it reaches through the base-class `AssociatedObject` +// accessor is co-lifetimed with the behavior — the same collectable source<->this +// cycle the shipped self-owned-source exemption already encodes for a constructed +// field. Matched SYNTACTICALLY by the direct base's simple name `Behavior` +// (mirroring IsProcessLivedApplication): the Microsoft.Xaml.Behaviors / +// System.Windows.Interactivity `Behavior`/`Behavior` base does not resolve on the +// Linux runner. Only the direct base is inspected — an intermediate user base +// (`class Concrete : MyBehavior`) is not chased (precision-first: no exemption, the +// honest warning stands), exactly as the App-partial precedent does. +static bool IsBehaviorSubscriber(TypeDeclarationSyntax cls) +{ + if (cls.BaseList is not { } bl) + return false; + foreach (var bt in bl.Types) + if (SimpleBaseName(bt.Type) == "Behavior") + return true; + return false; +} + +static string? SimpleBaseName(TypeSyntax t) => t switch +{ + IdentifierNameSyntax id => id.Identifier.Text, + GenericNameSyntax g => g.Identifier.Text, // Behavior + QualifiedNameSyntax q => SimpleBaseName(q.Right), // ...Interactivity.Behavior + AliasQualifiedNameSyntax aq => SimpleBaseName(aq.Name), + _ => null, +}; + +// #227: a bare/`this`-qualified read of the base-class `AssociatedObject` accessor — +// `this.AssociatedObject` or `AssociatedObject`. Matched by NAME (the property is +// inherited from the unresolved `Behavior` base), through casts/`!`. A member access +// qualified with anything else (`other.AssociatedObject`) reaches ANOTHER object and +// is not this behavior's own attached element, so it is rejected. +static bool IsAssociatedObjectAccess(ExpressionSyntax expr) +{ + expr = StripCasts(expr); + return expr switch + { + MemberAccessExpressionSyntax m => m.Name.Identifier.Text == "AssociatedObject" + && m.Expression is ThisExpressionSyntax, + IdentifierNameSyntax id => id.Identifier.Text == "AssociatedObject", + _ => false, + }; +} + +// #227: does `expr` provably resolve to `this.AssociatedObject` — directly, or through +// an assignment-chain-LOCAL step (a `var x = ...` initializer, an `is`-pattern +// designation, or a FIELD of this class assigned from it)? The provenance is +// syntactic and same-class (never interprocedural): a local carries the binding only +// when nothing rebinds it (IsNeverReassigned), and a field only when EVERY assignment +// to it in the class resolves to `AssociatedObject` (a single injected/constructed +// write anywhere denies the proof — precision-first, the worst case keeps the honest +// warning). Depth-bounded so a self-referential field cannot spin. +static bool ResolvesToAssociatedObject(ExpressionSyntax expr, SemanticModel model, + TypeDeclarationSyntax clsNode, int depth) +{ + if (depth > 4) + return false; + expr = StripCasts(expr); + if (IsAssociatedObjectAccess(expr)) + return true; + var sym = model.GetSymbolInfo(expr).Symbol; + if (sym is ILocalSymbol local) + { + // The declaration-site binding proves the local's value at the use only if + // nothing rebinds it (same conservative whole-member scan as #228). + if (!IsNeverReassigned(local, model)) + return false; + foreach (var r in local.DeclaringSyntaxReferences) + switch (r.GetSyntax()) + { + // var panel = this.AssociatedObject; + case VariableDeclaratorSyntax { Initializer.Value: { } init } + when ResolvesToAssociatedObject(init, model, clsNode, depth + 1): + return true; + // this.AssociatedObject is Panel panel / ... is { } panel + case SingleVariableDesignationSyntax des + when des.Ancestors().OfType().FirstOrDefault() + is { Expression: { } scrutinee } + && ResolvesToAssociatedObject(scrutinee, model, clsNode, depth + 1): + return true; + } + return false; + } + if (sym is IFieldSymbol field) + return FieldAssignedOnlyFromAssociatedObject(field, model, clsNode, depth); + return false; +} + +// #227: a field is a valid `AssociatedObject` alias only when it is populated ONLY +// from `AssociatedObject` — at least one such assignment, and no assignment to a +// value we cannot prove is `AssociatedObject` (an injected/constructed write would +// make the field's contents ambiguous at the `+=`). Class-level assignment scan, the +// same mechanism the field-based self-owned search already uses; the field-population +// evidence may live in the same `OnAttached` or any other member of the class. +static bool FieldAssignedOnlyFromAssociatedObject(IFieldSymbol field, SemanticModel model, + TypeDeclarationSyntax clsNode, int depth) +{ + var any = false; + foreach (var asg in clsNode.DescendantNodes().OfType()) + { + if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression)) + continue; + if (!SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(asg.Left).Symbol, field)) + continue; + any = true; + if (!ResolvesToAssociatedObject(asg.Right, model, clsNode, depth + 1)) + return false; + } + return any; +} + +// #227: the self-owned-source exemption for a `Behavior` reaching its own +// `AssociatedObject`. The `+=` receiver must resolve to `this.AssociatedObject` (or an +// assignment-chain-local/field/pattern-var provably drawn from it). Caller gates on +// IsBehaviorSubscriber — attaching/detaching guarantees co-lifetime ONLY in that +// pairing — so a lambda handler is fine here (unlike #228): capturing `this`/its +// locals just closes the collectable source<->behavior cycle, it does not pin a +// process-lived source to a shorter-lived capture. +static bool IsAssociatedObjectSource(ExpressionSyntax left, SemanticModel model, + TypeDeclarationSyntax clsNode) + => left is MemberAccessExpressionSyntax m + && ResolvesToAssociatedObject(m.Expression, model, clsNode, depth: 0); + // P-004 WPF MVVM ownership: a field read from `this.DataContext`, optionally through // an `as`/cast (`DataContext as VM`, `(VM)DataContext`). Combined with a view whose // own XAML CONSTRUCTS its DataContext, such a field is the view's owned view-model. @@ -4108,6 +4234,11 @@ or ImplicitObjectCreationExpressionSyntax // static-source region escape (OWN014) — `App` cannot be over-promoted. var clsIsApp = IsProcessLivedApplication(cls); + // #227: is this a `Behavior`-derived subscriber? Then a `+=` whose source is + // its own `AssociatedObject` (the attached element, co-lifetimed with the + // behavior) is the collectable self-owned cycle, not a leak. + var clsIsBehavior = IsBehaviorSubscriber(cls); + var subs = new List(); foreach (var a in assigns) { @@ -4140,6 +4271,18 @@ or ImplicitObjectCreationExpressionSyntax || (IsProcessLifetimeAppDomainEvent(ev) && HandlerRetainsNoInstance(a.Right, model)))) continue; + // P-004 / issue #227: a `Behavior` subscribing to (an element reached + // from) its own `AssociatedObject`. The behavior cannot outlive being + // attached, so the source is co-lifetimed with the subscriber — the + // same self-owned source<->this cycle as a constructed field, just + // reached through the base-class accessor. Gated on the `Behavior` + // base (co-lifetime holds ONLY in the attach/detach pairing) and on a + // same-class assignment-chain provenance to `AssociatedObject`; a + // subscription to an unrelated injected/constructed source in the same + // method keeps today's warning (its receiver does not resolve there). + if (!isTimer && clsIsBehavior + && IsAssociatedObjectSource(a.Left, model, cls)) + continue; // P-004 (issue #223): the curated weak-referenced-static-event allowlist — // unconditional (unlike the AppDomain exemption above, this does NOT gate on // HandlerRetainsNoInstance: the whole point of a weak-referenced source is diff --git a/frontend/roslyn/samples/AssociatedObjectSourceSample.cs b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs new file mode 100644 index 00000000..ecd18b90 --- /dev/null +++ b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs @@ -0,0 +1,144 @@ +// issue #227 — a `Behavior`-derived subscriber whose event SOURCE is (an element +// reached from) its own base-class `AssociatedObject`. A behavior is attached to and +// detached from exactly one element and cannot outlive being attached, so that element +// is co-lifetimed with the behavior — the same collectable source<->this cycle the +// shipped self-owned-source exemption already encodes for a constructed field, just +// reached through the base-class accessor. Real-world shape: MahApps.Metro +// TiltBehavior.cs:62-70 (found by the issue #201 oracle sweep). +// +// The exemption is deliberately NARROW: the subscriber must derive from `Behavior` +// (attach/detach guarantees co-lifetime ONLY in that pairing), and the source must +// resolve — same-class, assignment-chain-local — to `this.AssociatedObject`. The +// negative controls below pin each edge. +using System; + +namespace OwnSamples.AssociatedObject +{ + public class UiElement + { + public event EventHandler? Loaded; + public void Raise() => Loaded?.Invoke(this, EventArgs.Empty); + } + + public class Panel : UiElement { } + + // Stand-in for Microsoft.Xaml.Behaviors.Behavior / System.Windows.Interactivity: + // IsBehaviorSubscriber matches the base simple name `Behavior` syntactically (the + // Interactivity assembly does not resolve on the Linux runner). `AssociatedObject` + // is the base-class accessor for the attached element. + public class Behavior where T : class + { + protected T? AssociatedObject { get; set; } + } + + // An injected event bus for the negative controls — unknown, longer-lived source. + public class EventBus + { + public event EventHandler? Changed; + } + + // POSITIVE (silent) + the REQUIRED negative control in one `OnAttached`: the + // TiltBehavior shape — a field assigned from `AssociatedObject`, then an + // `is`-pattern local subscribed with a lambda handler — is SILENT; a subscription + // to an UNRELATED injected source in the SAME method stays flagged. + public class TiltLikeBehavior : Behavior + { + UiElement? _attached; + readonly EventBus _bus; + + public TiltLikeBehavior(EventBus bus) => _bus = bus; + + protected void OnAttached() + { + _attached = this.AssociatedObject; + if (_attached is Panel panel) + panel.Loaded += (s, e) => Handle(); // silent (#227): panel IS AssociatedObject + + _bus.Changed += (s, e) => Handle(); // OWN001: unrelated injected source + } + + void Handle() { } + } + + // POSITIVE (silent): the DIRECT receiver form — `this.AssociatedObject.Event`, no + // intermediate local, method-group handler. + public class DirectAssociatedBehavior : Behavior + { + protected void OnAttached() + { + this.AssociatedObject!.Loaded += OnLoaded; // silent (#227) + } + + void OnLoaded(object? sender, EventArgs e) { } + } + + // POSITIVE (silent): a bare-identifier local bound from `AssociatedObject`. + public class LocalAssociatedBehavior : Behavior + { + protected void OnAttached() + { + var el = AssociatedObject; + if (el is { } e0) + e0.Loaded += OnLoaded; // silent (#227) + } + + void OnLoaded(object? sender, EventArgs e) { } + } + + // CONTROL 1 (flagged): the SAME `AssociatedObject`-shaped subscription from a class + // that does NOT derive from `Behavior` — the co-lifetime guarantee comes from the + // attach/detach pairing, so the exemption gate stays the `Behavior` base. Here the + // member is an ordinary injected property, not the base accessor. + public class NotABehavior + { + UiElement AssociatedObject { get; } + + public NotABehavior(UiElement injected) => AssociatedObject = injected; + + public void Wire() + { + this.AssociatedObject.Loaded += OnLoaded; // OWN001: subscriber is not a Behavior + } + + void OnLoaded(object? sender, EventArgs e) { } + } + + // CONTROL 2 (flagged): the field is assigned from `AssociatedObject` in OnAttached + // but ALSO from an injected value in another member — its contents at the `+=` are + // ambiguous, so every assignment must resolve to `AssociatedObject` or the proof is + // denied. + public class MixedFieldBehavior : Behavior + { + UiElement? _el; + + protected void OnAttached() + { + _el = this.AssociatedObject; + if (_el is Panel panel) + panel.Loaded += OnLoaded; // OWN001: _el is also injected below + } + + public void Configure(UiElement injected) => _el = injected; + + void OnLoaded(object? sender, EventArgs e) { } + } + + // CONTROL 3 (flagged): the local STARTS as `AssociatedObject` but is REASSIGNED to + // an injected source before the `+=` — the declaration-site binding is stale. + public class ReassignedLocalBehavior : Behavior + { + readonly UiElement _injected; + + public ReassignedLocalBehavior(UiElement injected) => _injected = injected; + + protected void OnAttached() + { + var src = this.AssociatedObject; + src = _injected; // rebind -> declaration binding stale + if (src is { } s0) + s0.Loaded += OnLoaded; // OWN001: reassigned local + } + + void OnLoaded(object? sender, EventArgs e) { } + } +} From e3ec4fc8dd22bcc7652ef23ca755d3349ca5baf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:33:16 +0000 Subject: [PATCH 2/3] fix(extractor): resolve AssociatedObject's binding before exempting it (#227, Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsAssociatedObjectAccess matched the identifier `AssociatedObject` purely by text, so a local, a parameter, or a hidden member DECLARED in the Behavior subclass named `AssociatedObject` — holding an injected publisher, not the inherited accessor — would wrongly pass the self-owned-source gate (`void Wire(UiElement AssociatedObject) { AssociatedObject.Loaded += H; }`). That source is not co-lifetimed with the behavior, so the subscription must stay flagged. The name match now consults the symbol: the genuine base accessor is either UNRESOLVED (null — the Interactivity assembly is absent on the runner, the normal WPF case) or an INHERITED member (containing type is a base, not this class); a local/parameter binding, or a member declared on this class, is a shadow and denies the exemption. Conservative — an unresolvable symbol keeps today's behaviour. New flagged control ShadowParamBehavior (a parameter named AssociatedObject) + a CI assertion. Verified with the real extractor: all five controls warn, the three positives stay silent; full-sample-set output still byte-identical to main. Refs #227 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2AWb9N2VUsby2XpdftBcu --- .github/workflows/ci.yml | 5 +++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 36 +++++++++++++++---- .../samples/AssociatedObjectSourceSample.cs | 14 ++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98aa7a57..6cdc713b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -909,6 +909,11 @@ jobs: # source before the `+=` — the stale declaration binding must not exempt. echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignedLocalBehavior'" \ || { echo "FAIL: expected OWN001 when the AssociatedObject-bound local is reassigned before the +="; exit 1; } + # (4, Codex P2) a PARAMETER named `AssociatedObject` SHADOWS the inherited base + # accessor — the name matches by text, but the symbol is an injected parameter, + # so the exemption must resolve the binding, not just the name. + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ShadowParamBehavior'" \ + || { echo "FAIL: expected OWN001 when a shadowing parameter is named AssociatedObject (#227)"; exit 1; } 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" - name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2) run: | diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 584c00fc..be9ea81a 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1040,20 +1040,42 @@ static bool IsBehaviorSubscriber(TypeDeclarationSyntax cls) }; // #227: a bare/`this`-qualified read of the base-class `AssociatedObject` accessor — -// `this.AssociatedObject` or `AssociatedObject`. Matched by NAME (the property is -// inherited from the unresolved `Behavior` base), through casts/`!`. A member access -// qualified with anything else (`other.AssociatedObject`) reaches ANOTHER object and -// is not this behavior's own attached element, so it is rejected. -static bool IsAssociatedObjectAccess(ExpressionSyntax expr) +// `this.AssociatedObject` or `AssociatedObject`. The NAME is matched syntactically (the +// property is inherited from the `Behavior` base, which does not resolve on the Linux +// runner), through casts/`!`. A member access qualified with anything else +// (`other.AssociatedObject`) reaches ANOTHER object and is rejected. +// +// But the name alone is not enough: a local, a parameter, or a hidden member DECLARED in +// this class named `AssociatedObject` can SHADOW the inherited accessor and hold an +// injected publisher (`void Wire(UiElement AssociatedObject) { AssociatedObject.Loaded += +// H; }`) — that source is NOT co-lifetimed with the behavior (Codex P2). So the symbol is +// checked: the genuine base accessor is either UNRESOLVED (null — the Interactivity +// assembly is absent, the normal WPF case) or an INHERITED member (containing type is a +// base, not this class); a local/parameter binding, or a member declared on this class, +// is a shadow and denies the exemption. +static bool IsAssociatedObjectAccess(ExpressionSyntax expr, SemanticModel model, + TypeDeclarationSyntax clsNode) { expr = StripCasts(expr); - return expr switch + var nameMatches = expr switch { MemberAccessExpressionSyntax m => m.Name.Identifier.Text == "AssociatedObject" && m.Expression is ThisExpressionSyntax, IdentifierNameSyntax id => id.Identifier.Text == "AssociatedObject", _ => false, }; + if (!nameMatches) + return false; + var sym = model.GetSymbolInfo(expr).Symbol; + if (sym is null) + return true; // unresolved inherited accessor (the WPF runner case) + if (sym is ILocalSymbol or IParameterSymbol) + return false; // a shadowing local/parameter, not the accessor + if (sym is IFieldSymbol or IPropertySymbol + && model.GetDeclaredSymbol(clsNode) is { } clsSym + && SymbolEqualityComparer.Default.Equals(sym.ContainingType, clsSym)) + return false; // a hidden own member shadowing the accessor + return true; // an inherited (or resolvable base) accessor } // #227: does `expr` provably resolve to `this.AssociatedObject` — directly, or through @@ -1070,7 +1092,7 @@ static bool ResolvesToAssociatedObject(ExpressionSyntax expr, SemanticModel mode if (depth > 4) return false; expr = StripCasts(expr); - if (IsAssociatedObjectAccess(expr)) + if (IsAssociatedObjectAccess(expr, model, clsNode)) return true; var sym = model.GetSymbolInfo(expr).Symbol; if (sym is ILocalSymbol local) diff --git a/frontend/roslyn/samples/AssociatedObjectSourceSample.cs b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs index ecd18b90..b89ee5c9 100644 --- a/frontend/roslyn/samples/AssociatedObjectSourceSample.cs +++ b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs @@ -141,4 +141,18 @@ protected void OnAttached() void OnLoaded(object? sender, EventArgs e) { } } + + // CONTROL 4 (flagged, Codex P2): a PARAMETER named `AssociatedObject` SHADOWS the + // inherited base accessor — the identifier text matches, but the symbol is an + // injected parameter, not the co-lifetimed attached element, so the exemption must + // check the binding, not just the name. + public class ShadowParamBehavior : Behavior + { + public void Wire(UiElement AssociatedObject) + { + AssociatedObject.Loaded += OnLoaded; // OWN001: shadowing parameter + } + + void OnLoaded(object? sender, EventArgs e) { } + } } From e0f08677bae46dc8a7cb7c3944c07b7744336a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:43:17 +0000 Subject: [PATCH 3/3] fix(extractor): scan every partial declaration for the AssociatedObject field proof (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldAssignedOnlyFromAssociatedObject walked only the one class declaration holding the `+=`, so for a `partial` Behavior a disqualifying injected write to the field in a sibling partial FILE was invisible — the field could pass as an AssociatedObject alias while actually being injected elsewhere (same class of hole CodeRabbit flagged on the #229 sibling). It now scans every partial declaration of the field's containing type via the symbol's DeclaringSyntaxReferences (the merged compilation makes them all reachable), each with its own tree's semantic model. New flagged control PartialFieldBehavior (field assigned from AssociatedObject in one partial, from an injected value in the other) + a CI assertion. Verified with the real extractor: all six controls warn, the three positives stay silent; full-sample-set output byte-identical to main. Gates: run_tests 276/276, ruff, mypy, yaml all green. Refs #227 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2AWb9N2VUsby2XpdftBcu --- .github/workflows/ci.yml | 5 +++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 42 +++++++++++++------ .../samples/AssociatedObjectSourceSample.cs | 23 ++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cdc713b..e1c1ffd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -914,6 +914,11 @@ jobs: # so the exemption must resolve the binding, not just the name. echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ShadowParamBehavior'" \ || { echo "FAIL: expected OWN001 when a shadowing parameter is named AssociatedObject (#227)"; exit 1; } + # (5) a PARTIAL behavior whose field is assigned from AssociatedObject in one + # declaration but from an injected value in the sibling partial — the field- + # population scan must span every partial of the type. + echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'PartialFieldBehavior'" \ + || { echo "FAIL: expected OWN001 when a sibling partial injects the AssociatedObject field (#227)"; exit 1; } 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" - name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2) run: | diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index be9ea81a..7ca475b3 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1118,33 +1118,49 @@ when des.Ancestors().OfType().FirstOrDefault() return false; } if (sym is IFieldSymbol field) - return FieldAssignedOnlyFromAssociatedObject(field, model, clsNode, depth); + return FieldAssignedOnlyFromAssociatedObject(field, model, depth); return false; } // #227: a field is a valid `AssociatedObject` alias only when it is populated ONLY // from `AssociatedObject` — at least one such assignment, and no assignment to a // value we cannot prove is `AssociatedObject` (an injected/constructed write would -// make the field's contents ambiguous at the `+=`). Class-level assignment scan, the -// same mechanism the field-based self-owned search already uses; the field-population -// evidence may live in the same `OnAttached` or any other member of the class. +// make the field's contents ambiguous at the `+=`). Scans EVERY partial declaration of +// the field's containing type (a disqualifying injected write may live in a sibling +// partial FILE — the merged compilation makes them all reachable through the symbol's +// DeclaringSyntaxReferences), each with its own tree's semantic model. The field +// population evidence may thus live in the same `OnAttached` or any other member/partial +// of the class. static bool FieldAssignedOnlyFromAssociatedObject(IFieldSymbol field, SemanticModel model, - TypeDeclarationSyntax clsNode, int depth) + int depth) { var any = false; - foreach (var asg in clsNode.DescendantNodes().OfType()) + foreach (var decl in EnumerateTypeDeclarations(field.ContainingType)) { - if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression)) - continue; - if (!SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(asg.Left).Symbol, field)) - continue; - any = true; - if (!ResolvesToAssociatedObject(asg.Right, model, clsNode, depth + 1)) - return false; + var m = model.Compilation.GetSemanticModel(decl.SyntaxTree); + foreach (var asg in decl.DescendantNodes().OfType()) + { + if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression)) + continue; + if (!SymbolEqualityComparer.Default.Equals(m.GetSymbolInfo(asg.Left).Symbol, field)) + continue; + any = true; + if (!ResolvesToAssociatedObject(asg.Right, m, decl, depth + 1)) + return false; + } } return any; } +// The syntax declarations of a type symbol — every `partial` piece (in the merged +// compilation, across files). Used to scan a member's assignment sites wherever they live. +static IEnumerable EnumerateTypeDeclarations(INamedTypeSymbol type) +{ + foreach (var r in type.DeclaringSyntaxReferences) + if (r.GetSyntax() is TypeDeclarationSyntax td) + yield return td; +} + // #227: the self-owned-source exemption for a `Behavior` reaching its own // `AssociatedObject`. The `+=` receiver must resolve to `this.AssociatedObject` (or an // assignment-chain-local/field/pattern-var provably drawn from it). Caller gates on diff --git a/frontend/roslyn/samples/AssociatedObjectSourceSample.cs b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs index b89ee5c9..08096e0c 100644 --- a/frontend/roslyn/samples/AssociatedObjectSourceSample.cs +++ b/frontend/roslyn/samples/AssociatedObjectSourceSample.cs @@ -155,4 +155,27 @@ public void Wire(UiElement AssociatedObject) void OnLoaded(object? sender, EventArgs e) { } } + + // CONTROL 5 (flagged): a PARTIAL behavior whose field is assigned from + // `AssociatedObject` in one declaration but ALSO from an injected value in the + // sibling partial — the field-population scan must span every partial of the type, + // so the ambiguous field keeps the warning. + public partial class PartialFieldBehavior : Behavior + { + UiElement? _el; + + protected void OnAttached() + { + _el = this.AssociatedObject; + if (_el is Panel panel) + panel.Loaded += OnLoaded; // OWN001: _el also injected in the sibling partial + } + + void OnLoaded(object? sender, EventArgs e) { } + } + + public partial class PartialFieldBehavior + { + public void Inject(UiElement injected) => _el = injected; // sibling-partial injected write + } }