Skip to content

Commit e0f0867

Browse files
committed
fix(extractor): scan every partial declaration for the AssociatedObject field proof (#227)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2AWb9N2VUsby2XpdftBcu
1 parent e3ec4fc commit e0f0867

3 files changed

Lines changed: 57 additions & 13 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,11 @@ jobs:
914914
# so the exemption must resolve the binding, not just the name.
915915
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ShadowParamBehavior'" \
916916
|| { echo "FAIL: expected OWN001 when a shadowing parameter is named AssociatedObject (#227)"; exit 1; }
917+
# (5) a PARTIAL behavior whose field is assigned from AssociatedObject in one
918+
# declaration but from an injected value in the sibling partial — the field-
919+
# population scan must span every partial of the type.
920+
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'PartialFieldBehavior'" \
921+
|| { echo "FAIL: expected OWN001 when a sibling partial injects the AssociatedObject field (#227)"; exit 1; }
917922
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"
918923
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
919924
run: |

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

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,33 +1118,49 @@ when des.Ancestors().OfType<IsPatternExpressionSyntax>().FirstOrDefault()
11181118
return false;
11191119
}
11201120
if (sym is IFieldSymbol field)
1121-
return FieldAssignedOnlyFromAssociatedObject(field, model, clsNode, depth);
1121+
return FieldAssignedOnlyFromAssociatedObject(field, model, depth);
11221122
return false;
11231123
}
11241124

11251125
// #227: a field is a valid `AssociatedObject` alias only when it is populated ONLY
11261126
// from `AssociatedObject` — at least one such assignment, and no assignment to a
11271127
// value we cannot prove is `AssociatedObject` (an injected/constructed write would
1128-
// make the field's contents ambiguous at the `+=`). Class-level assignment scan, the
1129-
// same mechanism the field-based self-owned search already uses; the field-population
1130-
// evidence may live in the same `OnAttached` or any other member of the class.
1128+
// make the field's contents ambiguous at the `+=`). Scans EVERY partial declaration of
1129+
// the field's containing type (a disqualifying injected write may live in a sibling
1130+
// partial FILE — the merged compilation makes them all reachable through the symbol's
1131+
// DeclaringSyntaxReferences), each with its own tree's semantic model. The field
1132+
// population evidence may thus live in the same `OnAttached` or any other member/partial
1133+
// of the class.
11311134
static bool FieldAssignedOnlyFromAssociatedObject(IFieldSymbol field, SemanticModel model,
1132-
TypeDeclarationSyntax clsNode, int depth)
1135+
int depth)
11331136
{
11341137
var any = false;
1135-
foreach (var asg in clsNode.DescendantNodes().OfType<AssignmentExpressionSyntax>())
1138+
foreach (var decl in EnumerateTypeDeclarations(field.ContainingType))
11361139
{
1137-
if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression))
1138-
continue;
1139-
if (!SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(asg.Left).Symbol, field))
1140-
continue;
1141-
any = true;
1142-
if (!ResolvesToAssociatedObject(asg.Right, model, clsNode, depth + 1))
1143-
return false;
1140+
var m = model.Compilation.GetSemanticModel(decl.SyntaxTree);
1141+
foreach (var asg in decl.DescendantNodes().OfType<AssignmentExpressionSyntax>())
1142+
{
1143+
if (!asg.IsKind(SyntaxKind.SimpleAssignmentExpression))
1144+
continue;
1145+
if (!SymbolEqualityComparer.Default.Equals(m.GetSymbolInfo(asg.Left).Symbol, field))
1146+
continue;
1147+
any = true;
1148+
if (!ResolvesToAssociatedObject(asg.Right, m, decl, depth + 1))
1149+
return false;
1150+
}
11441151
}
11451152
return any;
11461153
}
11471154

1155+
// The syntax declarations of a type symbol — every `partial` piece (in the merged
1156+
// compilation, across files). Used to scan a member's assignment sites wherever they live.
1157+
static IEnumerable<TypeDeclarationSyntax> EnumerateTypeDeclarations(INamedTypeSymbol type)
1158+
{
1159+
foreach (var r in type.DeclaringSyntaxReferences)
1160+
if (r.GetSyntax() is TypeDeclarationSyntax td)
1161+
yield return td;
1162+
}
1163+
11481164
// #227: the self-owned-source exemption for a `Behavior` reaching its own
11491165
// `AssociatedObject`. The `+=` receiver must resolve to `this.AssociatedObject` (or an
11501166
// assignment-chain-local/field/pattern-var provably drawn from it). Caller gates on

‎frontend/roslyn/samples/AssociatedObjectSourceSample.cs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,27 @@ public void Wire(UiElement AssociatedObject)
155155

156156
void OnLoaded(object? sender, EventArgs e) { }
157157
}
158+
159+
// CONTROL 5 (flagged): a PARTIAL behavior whose field is assigned from
160+
// `AssociatedObject` in one declaration but ALSO from an injected value in the
161+
// sibling partial — the field-population scan must span every partial of the type,
162+
// so the ambiguous field keeps the warning.
163+
public partial class PartialFieldBehavior : Behavior<UiElement>
164+
{
165+
UiElement? _el;
166+
167+
protected void OnAttached()
168+
{
169+
_el = this.AssociatedObject;
170+
if (_el is Panel panel)
171+
panel.Loaded += OnLoaded; // OWN001: _el also injected in the sibling partial
172+
}
173+
174+
void OnLoaded(object? sender, EventArgs e) { }
175+
}
176+
177+
public partial class PartialFieldBehavior
178+
{
179+
public void Inject(UiElement injected) => _el = injected; // sibling-partial injected write
180+
}
158181
}

0 commit comments

Comments
 (0)