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
43 changes: 42 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -878,7 +879,47 @@ 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; }
# (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; }
# (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: |
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1
Expand Down
7 changes: 7 additions & 0 deletions docs/notes/field-notes-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
181 changes: 181 additions & 0 deletions frontend/roslyn/OwnSharp.Extractor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,170 @@
&& 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<T>` 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<T>
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`. 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);
var nameMatches = expr switch
{
MemberAccessExpressionSyntax m => m.Name.Identifier.Text == "AssociatedObject"
&& m.Expression is ThisExpressionSyntax,
IdentifierNameSyntax id => id.Identifier.Text == "AssociatedObject",
Comment on lines +1062 to +1064

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve AssociatedObject before exempting it

In a Behavior subclass, a local/parameter or a hidden member named AssociatedObject can refer to an injected publisher rather than the inherited behavior accessor (for example void Wire(UiElement AssociatedObject) { AssociatedObject.Loaded += OnLoaded; }). Because this branch accepts the identifier purely by text before checking GetSymbolInfo, IsAssociatedObjectSource will suppress the new subscription warning even though the source is not co-lifetimed with the behavior.

Useful? React with 👍 / 👎.

_ => 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
// 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, model, clsNode))
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<IsPatternExpressionSyntax>().FirstOrDefault()
is { Expression: { } scrutinee }
&& ResolvesToAssociatedObject(scrutinee, model, clsNode, depth + 1):
return true;
}
return false;
}
if (sym is IFieldSymbol field)
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 `+=`). 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,
int depth)
{
var any = false;
foreach (var decl in EnumerateTypeDeclarations(field.ContainingType))
{
var m = model.Compilation.GetSemanticModel(decl.SyntaxTree);
foreach (var asg in decl.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
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<TypeDeclarationSyntax> 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
// 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.
Expand Down Expand Up @@ -3931,7 +4095,7 @@
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
.ToList();
var refNames = new HashSet<string>(tpa.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase);

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / ownsharp CLI (gate A) — clean install -> check -> findings (ubuntu-latest)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

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

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / ownsharp CLI (gate A) — clean install -> check -> findings (windows-latest)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / ownsharp CLI (gate A) — clean install -> check -> findings (ubuntu-latest)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / ownsharp CLI (gate A) — clean install -> check -> findings (windows-latest)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 4098 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

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

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.
var references = tpa.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
// P-004 WPF profile: widen the reference set with assemblies named by the
// OWN_EXTRA_REF_DIRS env var (colon-separated dirs) — e.g. the WindowsDesktop ref
Expand Down Expand Up @@ -4108,6 +4272,11 @@
// 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<object>();
foreach (var a in assigns)
{
Expand Down Expand Up @@ -4140,6 +4309,18 @@
|| (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
Expand Down
Loading
Loading