diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8da46a6c..52533551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,8 @@ jobs: frontend/roslyn/samples/PooledBufferSample.cs \ frontend/roslyn/samples/LocalDisposableSample.cs \ frontend/roslyn/samples/SelfOwnedViewModel.cs \ + frontend/roslyn/samples/SelfOwnedControlParts.cs \ + frontend/roslyn/samples/ExternalRefSubscription.cs \ frontend/roslyn/samples/StaticHandlerViewModel.cs \ frontend/roslyn/samples/SampleTypes.cs \ -o "$RUNNER_TEMP/facts.json" @@ -215,6 +217,18 @@ jobs: if echo "$out" | grep -q "SelfOwnedViewModel.cs"; then echo "FAIL: a self-owned subscription was wrongly reported"; exit 1 fi + # P-004 self-owned (extended): a field built indirectly via a `ref`/`out` + # helper, or fetched as one of the control's own template parts + # (GetTemplateChild), is owned just like a `new`'d field — both + # subscriptions in SelfOwnedControlParts are collectable cycles -> silent. + if echo "$out" | grep -q "SelfOwnedControlParts.cs"; then + echo "FAIL: a self-owned (ref-built / template-part) subscription was wrongly reported"; exit 1 + fi + # P-004 (ref/out narrowing, Codex P2): a field populated by an EXTERNAL + # class's ref method (not this class's own helper) is NOT self-owned — the + # subscription must still be reported, not silently suppressed. + echo "$out" | grep -qE "ExternalRefSubscription\.cs:[0-9]+: warning: \[OWN001\]" \ + || { echo "FAIL: expected OWN001 on the external-ref subscription (must not be exempted)"; exit 1; } # P-004 static-handler exemption: a static-method handler has a null # delegate target — no instance retained, so not a leak — silent. if echo "$out" | grep -q "StaticHandlerViewModel.cs"; then @@ -241,21 +255,23 @@ jobs: || { echo "FAIL: expected the never-disposed wording for the 0-release Timer"; exit 1; } echo "$out" | grep -qE "'leak' may not be disposed on every path" \ || { echo "FAIL: expected the partial-path wording for LeakOnElse"; exit 1; } - # P-016 A1 reached the frontend: `while`/`foreach` bodies are now lowered - # (not skipped), so a per-iteration leak in one is caught. + # P-016 A1 reached the frontend: `while`/`foreach`/`for` bodies are now + # lowered (not skipped), so a per-iteration leak in one is caught. echo "$out" | grep -qE "OWN001.*'whileLeak' is never disposed" \ || { echo "FAIL: expected OWN001 on the undisposed local in a while loop"; exit 1; } echo "$out" | grep -qE "OWN001.*'foreachLeak'" \ || { echo "FAIL: expected OWN001 on the undisposed local in a foreach loop"; exit 1; } - # dispose-optional (Task), disposed/escaping locals, a `for` loop (still - # skipped: `looped`), and a balanced acquire+dispose in a loop (`whileClean`) - # must stay silent: + echo "$out" | grep -qE "OWN001.*'forLeak'" \ + || { echo "FAIL: expected OWN001 on the undisposed local in a for loop"; exit 1; } + # dispose-optional (Task), disposed/escaping locals, a `for` loop whose + # disposable is disposed after it (`looped`, balanced), and a balanced + # acquire+dispose in a loop (`whileClean`) must stay silent: # released via `await x.DisposeAsync()` (asyncDisposed) and the chained # `.ConfigureAwait(false)` form (asyncDisposedCfg) -> both must stay silent. for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg; do if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi done - echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach, never-vs-every-path wording, dispose-optional exempt, beyond flat)" + echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, never-vs-every-path wording, dispose-optional exempt, beyond flat)" - name: Coverage summary (--stats) run: | # --stats prints a flow-locals coverage line to stderr and stamps the same diff --git a/.github/workflows/mine-on-push.yml b/.github/workflows/mine-on-push.yml new file mode 100644 index 00000000..6cc68dd1 --- /dev/null +++ b/.github/workflows/mine-on-push.yml @@ -0,0 +1,105 @@ +name: mine (on push) + +# Autonomous corpus mining for the eval loop. When corpus/mine-target.txt changes +# on the dev branch, clone the named public C# repo and run the Own.NET leak check +# over it — same tooling as mine.yml (docs/notes/mining.md), but push-triggered so +# the loop needs no manual workflow_dispatch. The target is read from the committed +# sentinel file and ALLOWLIST-validated before use (never interpolated raw into a +# shell; passed to the miner via env). One repo per run. Dev-branch only — remove +# before merging to main. + +on: + push: + branches: + - claude/zen-pasteur-76hfs1 + paths: + - corpus/mine-target.txt + +permissions: + contents: read + +jobs: + mine: + name: mine (sentinel) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Materialize WPF reference assemblies (WPF profile) + run: | + # The WindowsDesktop ref pack isn't in the Linux SDK, but a net8.0-windows + # /UseWPF stub restores it cross-platform (EnableWindowsTargeting). Export + # the ref dir so the extractor (OWN_EXTRA_REF_DIRS) can resolve WPF events + # and timers instead of dropping them as OWN050. + tmp=$(mktemp -d) + printf '%s\n' \ + '' \ + ' ' \ + ' net8.0-windows' \ + ' true' \ + ' true' \ + ' ' \ + '' > "$tmp/wpfref.csproj" + dotnet restore "$tmp/wpfref.csproj" >/dev/null 2>&1 || echo "wpf-ref restore failed (continuing without)" + d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true) + if [ -n "$d" ]; then + echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV" + echo "WPF refs ready: $d ($(find "$d" -name '*.dll' | wc -l) dlls)" + else + echo "WPF refs NOT found — the mine will run without them (OWN050 on framework types)" + fi + - name: Read & validate the target + id: target + run: | + file=corpus/mine-target.txt + # First non-comment, non-empty line: "owner/repo" or an https git URL. + target=$(grep -vE '^[[:space:]]*(#|$)' "$file" | head -1 | tr -d '[:space:]') + ref=$(grep -E '^ref=' "$file" | head -1 | sed 's/^ref=//' | tr -d '[:space:]') + paths=$(grep -E '^paths=' "$file" | head -1 | sed 's/^paths=//' | tr -d '[:space:]') + # Allowlist: GitHub owner/repo, or an https git URL. Reject anything else + # so a stray sentinel value can't smuggle shell or an odd scheme. + if ! [[ "$target" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$target" =~ ^https://[A-Za-z0-9./_-]+$ ]]; then + echo "mine-on-push: invalid target '$target' in $file" >&2; exit 2 + fi + { echo "target=$target"; echo "ref=$ref"; echo "paths=$paths"; } >> "$GITHUB_OUTPUT" + echo "mine-on-push: $target (ref='${ref:-HEAD}' paths='${paths:-*}')" + - name: Mine the target + env: + REPO: ${{ steps.target.outputs.target }} + REF: ${{ steps.target.outputs.ref }} + PATHS: ${{ steps.target.outputs.paths }} + run: | + args=() + [[ -n "$REF" ]] && args+=(--ref "$REF") + [[ -n "$PATHS" ]] && args+=(--paths "$PATHS") + scripts/mine.sh "${args[@]}" "$REPO" + - name: Surface the report (log + summary) + if: always() + run: | + report=$(find corpus/mined -name report.md -type f 2>/dev/null | head -1 || true) + findings=$(find corpus/mined -name findings.txt -type f 2>/dev/null | head -1 || true) + if [[ -n "$report" ]]; then + cat "$report" >> "$GITHUB_STEP_SUMMARY" + echo "::group::report.md"; cat "$report"; echo "::endgroup::" + else + echo "no report produced (see the Mine step log)" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -n "$findings" ]]; then + echo "::group::findings.txt"; cat "$findings"; echo "::endgroup::" + fi + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mine-report + path: | + corpus/mined/*/report.md + corpus/mined/*/report.json + corpus/mined/*/findings.txt + corpus/mined/*/extract.log + if-no-files-found: warn diff --git a/.github/workflows/mine.yml b/.github/workflows/mine.yml index c3ca10ed..343937be 100644 --- a/.github/workflows/mine.yml +++ b/.github/workflows/mine.yml @@ -51,11 +51,18 @@ jobs: if: always() run: | report=$(find corpus/mined -name report.md -type f 2>/dev/null | head -1 || true) + findings=$(find corpus/mined -name findings.txt -type f 2>/dev/null | head -1 || true) if [[ -n "$report" ]]; then cat "$report" >> "$GITHUB_STEP_SUMMARY" + # Also echo to stdout so the report is readable straight from the job + # log (the API/agent eval loop reads logs, not the summary or artifact). + echo "::group::report.md"; cat "$report"; echo "::endgroup::" else echo "no report produced (see the Mine step log)" >> "$GITHUB_STEP_SUMMARY" fi + if [[ -n "$findings" ]]; then + echo "::group::findings.txt"; cat "$findings"; echo "::endgroup::" + fi - name: Upload the report if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 2d393f79..e4a7ef5e 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -39,6 +39,14 @@ on: required: false type: boolean default: false + # Dev-loop fallback (the automation token can't `workflow_dispatch`): bump the + # sentinel corpus/oracle-target.txt to run the oracle push-triggered, reading the + # target from that file. Dev-branch only — remove before merging to main. + push: + branches: + - claude/zen-pasteur-76hfs1 + paths: + - corpus/oracle-target.txt permissions: contents: read @@ -57,35 +65,113 @@ jobs: with: dotnet-version: "8.0.x" + # Resolve the target: workflow_dispatch inputs win; on push, read the + # sentinel corpus/oracle-target.txt (same format/allowlist as mine-on-push). + # Everything downstream reads steps.t.outputs.* so both triggers share steps. + - name: Resolve target + id: t + env: + IN_REPO: ${{ inputs.repo }} + IN_REF: ${{ inputs.ref }} + IN_PATHS: ${{ inputs.paths }} + IN_BUILD: ${{ inputs.build }} + IN_TESTS: ${{ inputs.include_tests }} + run: | + file=corpus/oracle-target.txt + if [[ -n "$IN_REPO" ]]; then + repo="$IN_REPO"; ref="$IN_REF"; paths="$IN_PATHS"; build="$IN_BUILD"; tests="$IN_TESTS" + else + repo=$(grep -vE '^[[:space:]]*(#|$)' "$file" | head -1 | tr -d '[:space:]') + ref=$(grep -E '^ref=' "$file" | head -1 | sed 's/^ref=//' | tr -d '[:space:]') + paths=$(grep -E '^paths=' "$file" | head -1 | sed 's/^paths=//' | tr -d '[:space:]') + build=$(grep -E '^build=' "$file" | head -1 | sed 's/^build=//' | tr -d '[:space:]') + tests=$(grep -E '^include_tests=' "$file"| head -1 | sed 's/^include_tests=//' | tr -d '[:space:]') + fi + # Allowlist: GitHub owner/repo, an https URL, or local: + # (a fixture copied into target/ instead of cloned) — never an odd scheme. + if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$repo" =~ ^https://[A-Za-z0-9./_-]+$ || "$repo" =~ ^local:[A-Za-z0-9._/-]+$ ]]; then + echo "oracle: invalid target '$repo'" >&2; exit 2 + fi + { echo "repo=$repo"; echo "ref=$ref"; echo "paths=$paths"; + echo "build=$build"; echo "tests=${tests:-false}"; } >> "$GITHUB_OUTPUT" + echo "oracle target: $repo (ref='${ref:-HEAD}' paths='${paths:-*}' build='${build:-auto}' tests='${tests:-false}')" + # Fast fail: the diff logic is unit-tested before we clone/build anything. - name: Comparator selftest run: python scripts/oracle_compare.py --selftest - name: Clone the target env: - REPO: ${{ inputs.repo }} - REF: ${{ inputs.ref }} + REPO: ${{ steps.t.outputs.repo }} + REF: ${{ steps.t.outputs.ref }} + run: | + if [[ "$REPO" == local:* ]]; then + # local: — copy an in-repo fixture into target/ instead of + # cloning, so a tiny buildable repro (where ScreenToGif can't build on + # Linux) lets every oracle — including Infer#, which needs binaries — run. + src="${REPO#local:}" + [[ "$src" != *..* && -d "$src" ]] || { echo "oracle: bad local fixture '$src'" >&2; exit 2; } + cp -r "$src" target + echo "COMMIT=local@$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + echo "using in-repo fixture: $src" + else + case "$REPO" in + http://*|https://*|git@*) url="$REPO" ;; + *) url="/${REPO}.git" ;; + esac + if [[ -n "$REF" ]]; then + # A blobless clone keeps the full commit/ref graph (blobs fetched on + # demand), so ANY ref — branch, tag, or an abbreviated SHA — resolves + # locally at checkout. A shallow `fetch origin ` does NOT: + # servers reject abbreviated / unadvertised SHAs in a want request. + git clone --filter=blob:none --quiet "$url" target + git -C target checkout --quiet --detach "$REF" + else + git clone --depth 1 --quiet "$url" target + fi + echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV" + fi + + # Framework-type references for own-check. The Roslyn extractor resolves a + # `+=` only when the event's declaring type is on its reference set — else the + # subscription is an OWN050 "unchecked" note, not a leak. Materialize the + # WindowsDesktop ref pack (WPF / WinForms / Microsoft.Win32.SystemEvents) and + # export OWN_EXTRA_REF_DIRS so own-check resolves framework events instead of + # dropping them — putting it on equal footing with CodeQL, which resolves + # types from source. Harmless for non-Windows targets (deduped vs the runtime). + - name: Materialize framework reference assemblies + continue-on-error: true run: | - case "$REPO" in - http://*|https://*|git@*) url="$REPO" ;; - *) url="/${REPO}.git" ;; - esac - git clone --depth 1 --quiet "$url" target - if [[ -n "$REF" ]]; then - # --branch only accepts a branch/tag; fetch+checkout also takes a raw SHA. - git -C target fetch --depth 1 --quiet origin "$REF" - git -C target checkout --quiet --detach FETCH_HEAD + tmp=$(mktemp -d) + printf '%s\n' \ + '' \ + ' ' \ + ' net8.0-windows' \ + ' true' \ + ' true' \ + ' true' \ + ' ' \ + '' > "$tmp/ref.csproj" + dotnet restore "$tmp/ref.csproj" >/dev/null 2>&1 || echo "ref restore failed (continuing)" + d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true) + if [ -n "$d" ]; then + echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV" + echo "framework refs: $d ($(find "$d" -name '*.dll' | wc -l) dlls)" + else + echo "framework refs not found — own-check resolves runtime types only" fi - echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV" - # Own.NET — no build needed; scans .cs directly. + # Own.NET — no build needed; scans .cs directly. --severity warning so the + # warning-tier leaks (injected-source subscriptions, e.g. VideoSource) are + # included, not just the provable static-source errors. OWN_EXTRA_REF_DIRS + # (above) is inherited by the extractor process. - name: Own.NET own-check env: - PATHS: ${{ inputs.paths }} + PATHS: ${{ steps.t.outputs.paths }} run: | scan="target"; [[ -n "$PATHS" ]] && scan="target/$PATHS" set +e - scripts/own-check.sh --format human -- "$scan" > own.txt 2> own-extract.log + scripts/own-check.sh --format human --severity warning -- "$scan" > own.txt 2> own-extract.log echo "own-check rc=$? ; $(wc -l < own.txt) finding line(s)" # CodeQL — database from source (no build). The dispose/leak queries @@ -115,8 +201,8 @@ jobs: # projects); else a lone solution (.sln/.slnx); else the dir (-> partial). - name: Build the target (for Infer#) env: - BUILD: ${{ inputs.build }} - REPO: ${{ inputs.repo }} + BUILD: ${{ steps.t.outputs.build }} + REPO: ${{ steps.t.outputs.repo }} continue-on-error: true run: | if [[ -n "$BUILD" ]]; then @@ -162,8 +248,8 @@ jobs: - name: Diff Own.NET vs the oracles if: always() env: - REPO: ${{ inputs.repo }} - INCLUDE_TESTS: ${{ inputs.include_tests }} + REPO: ${{ steps.t.outputs.repo }} + INCLUDE_TESTS: ${{ steps.t.outputs.tests }} run: | args=(--own own.txt --target "$REPO" --commit "${COMMIT:-}" --strip "$GITHUB_WORKSPACE/target" --strip target diff --git a/corpus/fixtures/systemevents-console/Program.cs b/corpus/fixtures/systemevents-console/Program.cs new file mode 100644 index 00000000..fcd43504 --- /dev/null +++ b/corpus/fixtures/systemevents-console/Program.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using Microsoft.Win32; + +namespace SystemEventsLeak; + +// Two leak CLASSES in one tiny, Linux-buildable program, so the cross-tool oracle +// can run Own.NET, CodeQL AND Infer# over the same code. ScreenToGif (the real +// finding) is WPF and does not build on the Linux oracle runner, so Infer# was +// skipped there; this builds on Linux, so all three tools run. +// See README.md for the expected 2x2. +public sealed class DisplayWatcher +{ + // (1) SUBSCRIPTION leak — Own.NET's class. SystemEvents is a static, + // process-lifetime source; subscribing without ever unsubscribing pins the + // subscriber for the life of the process. The RAII / dataflow oracles have no + // "event subscribed, never unsubscribed" query, so they should miss this. + public DisplayWatcher() + { + SystemEvents.DisplaySettingsChanged += OnDisplayChanged; + // ...no `-=` anywhere -> leak + } + + private void OnDisplayChanged(object? sender, EventArgs e) { } +} + +public static class Program +{ + public static void Main() + { + _ = new DisplayWatcher(); + LeakAFile(); + } + + // (2) DISPOSE leak — CodeQL's / Infer#'s class, and the control: a local + // IDisposable never disposed. All three tools should flag this, which proves the + // RAII oracles actually ran on the fixture — so a miss on (1) is a real + // capability gap, not an empty run. + private static void LeakAFile() + { + var stream = new FileStream("scratch.bin", FileMode.Create); + stream.WriteByte(0x42); + // ...no Dispose()/using -> resource leak + } +} diff --git a/corpus/fixtures/systemevents-console/README.md b/corpus/fixtures/systemevents-console/README.md new file mode 100644 index 00000000..6cddeaee --- /dev/null +++ b/corpus/fixtures/systemevents-console/README.md @@ -0,0 +1,29 @@ +# Cross-tool oracle fixture — SystemEvents subscription leak (Linux-buildable) + +A minimal `net8.0` console app reproducing **two leak classes**, so the oracle +(`oracle.yml`) can run all three tools — Own.NET, CodeQL **and Infer#** — over the +same code. ScreenToGif (the real finding) is WPF and does not `dotnet build` on the +Linux oracle runner, so Infer# was skipped there; this fixture builds on Linux, so +Infer# runs and the cross-tool picture is complete. + +The two leaks (`Program.cs`): + +| # | leak | class | expected to flag | +|---|------|-------|------------------| +| 1 | `SystemEvents.DisplaySettingsChanged += …`, never `-=` | subscription / lifetime | **Own.NET only** | +| 2 | `new FileStream(…)` local, never disposed | Dispose / RAII | **all three** (the control) | + +#2 is the agreement that proves the RAII oracles ran on the fixture; #1 is the +differentiator — Own.NET flags it, CodeQL / Infer# have no query for the +subscription-leak class. A clean 2×2 for the differentiation thesis. + +Run via the oracle's local-fixture mode — set `corpus/oracle-target.txt` to: + +``` +local:corpus/fixtures/systemevents-console +build=SystemEventsLeak.csproj +``` + +The `local:` target (copied into the oracle's `target/` instead of cloned) and the +sentinel are dev-loop scaffolding, like the rest of the oracle push path — not for +`main`. diff --git a/corpus/fixtures/systemevents-console/SystemEventsLeak.csproj b/corpus/fixtures/systemevents-console/SystemEventsLeak.csproj new file mode 100644 index 00000000..113f594a --- /dev/null +++ b/corpus/fixtures/systemevents-console/SystemEventsLeak.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + disable + + CA1416 + + + + + + + diff --git a/corpus/mine-target.txt b/corpus/mine-target.txt new file mode 100644 index 00000000..cff56dee --- /dev/null +++ b/corpus/mine-target.txt @@ -0,0 +1,9 @@ +# Target for the push-triggered miner (.github/workflows/mine-on-push.yml). +# First non-comment line: a GitHub "owner/repo" or an https git URL. One repo/run. +# Optional lines: ref=, paths=. +# Bump this file (commit + push) to kick off a mine; read the result in the run log. +# cycle: re-mine WITH the WPF reference pack loaded (OWN_EXTRA_REF_DIRS). +# 2026-06-17: re-mine to confirm the self-owned exemption extension (ref/out-built +# fields + GetTemplateChild template parts) drops the OWN001 self-owned-control +# false positives from the 123 baseline. +NickeManarin/ScreenToGif diff --git a/corpus/oracle-target.txt b/corpus/oracle-target.txt new file mode 100644 index 00000000..2557be31 --- /dev/null +++ b/corpus/oracle-target.txt @@ -0,0 +1,11 @@ +# Target for the push-triggered oracle (.github/workflows/oracle.yml, push path). +# Format = mine-target.txt: first non-comment line is "owner/repo", an https URL, +# or "local:" (a fixture copied into the oracle's target/). +# Optional lines: ref=, paths=, build=, include_tests=. Dev-branch only. +# +# Cross-tool oracle on a Linux-buildable fixture, so ALL THREE tools run (Infer# +# included — ScreenToGif's WPF won't build on Linux). Expected 2x2: the FileStream +# Dispose leak agrees across tools; the SystemEvents subscription leak is Own.NET +# only. See corpus/fixtures/systemevents-console/README.md. +local:corpus/fixtures/systemevents-console +build=SystemEventsLeak.csproj diff --git a/corpus/real-world/screentogif-loaded-subscription/after.cs b/corpus/real-world/screentogif-loaded-subscription/after.cs new file mode 100644 index 00000000..56f52f9c --- /dev/null +++ b/corpus/real-world/screentogif-loaded-subscription/after.cs @@ -0,0 +1,34 @@ +// Fix: subscribe with named handlers (so they have a `-=` handle) and detach them +// in Window_Closing. The view-model no longer roots the window, and a repeated +// Loaded no longer stacks duplicate handlers. +using System; +using System.Windows; + +public partial class VideoSource : Window +{ + private readonly VideoSourceViewModel _viewModel; + + public VideoSource() + { + InitializeComponent(); + _viewModel = DataContext as VideoSourceViewModel; + } + + private void Window_Loaded(object sender, RoutedEventArgs e) + { + _viewModel.ShowErrorRequested += OnShowError; + _viewModel.HideErrorRequested += OnHideError; + _viewModel.CloseRequested += OnClose; + } + + private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + _viewModel.ShowErrorRequested -= OnShowError; + _viewModel.HideErrorRequested -= OnHideError; + _viewModel.CloseRequested -= OnClose; + } + + private void OnShowError(object sender, EventArgs args) => StatusBand.Error(args?.ToString()); + private void OnHideError(object sender, EventArgs e) => StatusBand.Hide(); + private void OnClose(object sender, EventArgs e) => DialogResult = true; +} diff --git a/corpus/real-world/screentogif-loaded-subscription/before.cs b/corpus/real-world/screentogif-loaded-subscription/before.cs new file mode 100644 index 00000000..782bde50 --- /dev/null +++ b/corpus/real-world/screentogif-loaded-subscription/before.cs @@ -0,0 +1,32 @@ +// Reduced from NickeManarin/ScreenToGif @ 27a49c3, +// ScreenToGif/Windows/Other/VideoSource.xaml.cs:46-90 — found by mining (P-004). +// +// A Window subscribes lambdas to its view-model's events in Window_Loaded and +// never detaches them. Two problems: (1) Loaded can fire more than once (the +// element is re-added to the visual tree) -> duplicate handlers stack up; (2) the +// lambdas capture `this`, so the view-model holds the window alive for as long as +// the view-model itself is reachable. There is no `-=` anywhere in the file. +using System; +using System.Windows; + +public partial class VideoSource : Window +{ + private readonly VideoSourceViewModel _viewModel; + + public VideoSource() + { + InitializeComponent(); + _viewModel = DataContext as VideoSourceViewModel; + } + + private void Window_Loaded(object sender, RoutedEventArgs e) + { + _viewModel.ShowErrorRequested += (_, args) => StatusBand.Error(args?.ToString()); + _viewModel.HideErrorRequested += (_, _) => StatusBand.Hide(); + _viewModel.CloseRequested += (_, _) => DialogResult = true; + // ...never unsubscribed -> OWN001 (handler leak) + } + + // Present in the real file, but it does NOT detach the handlers above. + private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) { } +} diff --git a/corpus/real-world/screentogif-loaded-subscription/case.own b/corpus/real-world/screentogif-loaded-subscription/case.own new file mode 100644 index 00000000..0d882bb4 --- /dev/null +++ b/corpus/real-world/screentogif-loaded-subscription/case.own @@ -0,0 +1,16 @@ +// OwnLang model of a real WPF view->view-model leak found by mining +// NickeManarin/ScreenToGif (P-004 milestone 1). In VideoSource.xaml.cs the +// Window's Loaded handler subscribes lambdas to its view-model's events and +// never detaches them. A subscription is acquire/release (subscribe/unsubscribe); +// the missing release is the generic OWN001 — see notes.md for the real finding, +// its provenance, and why the C# extractor rates it a *warning* (injected source). +module Corpus +resource Subscription { + acquire Subscribe + release Unsubscribe + kind "subscription token" +} +fn Window_Loaded(viewModel: int) { + let showError = acquire Subscription(viewModel); // _viewModel.ShowErrorRequested += (_, e) => ... + // no `release showError;` — Window_Closing never does `-=` -> handler leak (OWN001) +} diff --git a/corpus/real-world/screentogif-loaded-subscription/expected-diagnostics.txt b/corpus/real-world/screentogif-loaded-subscription/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/screentogif-loaded-subscription/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/screentogif-loaded-subscription/notes.md b/corpus/real-world/screentogif-loaded-subscription/notes.md new file mode 100644 index 00000000..1a38deb1 --- /dev/null +++ b/corpus/real-world/screentogif-loaded-subscription/notes.md @@ -0,0 +1,40 @@ +# ScreenToGif — view subscribes to its view-model in `Loaded`, never detaches + +**Found by mining** (P-004 milestone 1, see `docs/notes/real-world-mining.md`). +Target: `NickeManarin/ScreenToGif` @ `27a49c3`, file +`ScreenToGif/Windows/Other/VideoSource.xaml.cs:50-83`. + +**Pattern.** A WPF `Window` whose `_viewModel = DataContext as VideoSourceViewModel` +wires **four inline-lambda subscriptions** to the view-model's custom events inside +`Window_Loaded` (`ShowErrorRequested`, `HideErrorRequested`, `ShowWarningRequested`, +`CloseRequested`) and **never detaches them** — `Window_Closing` is present but does +no `-=`. Each lambda captures `this`, so the view-model holds a strong reference to +the window; and because `Loaded` can fire more than once, the handlers can stack up. + +**What the checker says (real extractor output, with the WPF profile off):** + +```text +VideoSource.xaml.cs:50: warning: [OWN001] event '_viewModel.ShowErrorRequested' is + subscribed (handler '(_, args) => ...') but never unsubscribed; its source is an + injected dependency whose lifetime is unknown, so it may outlive and keep + 'VideoSource' alive (possible leak — and being an inline lambda it has no '-=' + handle, so it could never be detached) [resource: subscription token] +``` + +These resolve **without** the WPF reference pack because `_viewModel`'s events are +the app's own types — exactly the differentiated view↔view-model lifetime shape +that generic IDisposable/CA analyzers don't flag. + +**Why warning, not error (the honest part).** The C# extractor's source tiering +(P-004) rates this **warning**: `_viewModel` is an injected field, so the extractor +cannot *prove* it outlives the window. In fact, since the view-model is the window's +own `DataContext`, the two most likely share a lifetime (a collectable cycle), so +this may not leak memory at all — but the **duplicate-handler-on-reload** bug is +real regardless. "Possible leak" is the correct verdict, and the lambda note flags +the sharper problem: there is no handle to detach with. + +**This `case.own`** models the subscription as a generic acquire/release and so +produces the core's domain-neutral **OWN001** (the severity tiering lives in the C# +extractor, above the core). As with the rest of the corpus, `case.own` is a hand +reduction of the C# pattern, not verbatim extractor output; `before.cs` / `after.cs` +are representative of the leak and its fix. diff --git a/corpus/real-world/screentogif-systemevents-leak/after.cs b/corpus/real-world/screentogif-systemevents-leak/after.cs new file mode 100644 index 00000000..f353a95c --- /dev/null +++ b/corpus/real-world/screentogif-systemevents-leak/after.cs @@ -0,0 +1,23 @@ +// Fix: unsubscribe when the window is done (here, on Closed), breaking the static +// source's hold so the window can be collected. +using System; +using System.Windows; +using Microsoft.Win32; + +public partial class GraphicsConfigurationDialog : Window +{ + public GraphicsConfigurationDialog() + { + InitializeComponent(); + SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged; + Closed += OnClosed; + } + + private void OnClosed(object sender, EventArgs e) + { + SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged; + Closed -= OnClosed; + } + + private void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) { } +} diff --git a/corpus/real-world/screentogif-systemevents-leak/before.cs b/corpus/real-world/screentogif-systemevents-leak/before.cs new file mode 100644 index 00000000..f531e57e --- /dev/null +++ b/corpus/real-world/screentogif-systemevents-leak/before.cs @@ -0,0 +1,25 @@ +// Reduced from NickeManarin/ScreenToGif @ 27a49c3 — two independent occurrences of +// the same pattern, found by mining (P-004): +// ScreenToGif/Windows/Other/GraphicsConfigurationDialog.xaml.cs:35 +// ScreenToGif/Windows/Other/Troubleshoot.xaml.cs:27 +// +// A Window subscribes to Microsoft.Win32.SystemEvents — a STATIC, process-lifetime +// event source — and never unsubscribes. The static source holds a strong +// reference to the handler's owner (the Window) for the entire life of the +// process: the window closes, but it cannot be collected. This is the textbook +// SystemEvents leak (the docs explicitly warn about it). +using System; +using System.Windows; +using Microsoft.Win32; + +public partial class GraphicsConfigurationDialog : Window +{ + public GraphicsConfigurationDialog() + { + InitializeComponent(); + SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged; + // ...never `-=`'d -> the process-lived SystemEvents pins this dialog (OWN001, error) + } + + private void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) { } +} diff --git a/corpus/real-world/screentogif-systemevents-leak/case.own b/corpus/real-world/screentogif-systemevents-leak/case.own new file mode 100644 index 00000000..ba8a7591 --- /dev/null +++ b/corpus/real-world/screentogif-systemevents-leak/case.own @@ -0,0 +1,17 @@ +// OwnLang model of a real WPF leak found by mining NickeManarin/ScreenToGif +// (P-004 milestone 1). Microsoft.Win32.SystemEvents is a STATIC, process-lifetime +// event source: a window that subscribes to DisplaySettingsChanged and never +// unsubscribes is pinned alive for the whole process. Modelled as a subscription +// acquire/release, the missing release is the generic OWN001 — and because the +// source is static (provably outlives the window) the C# extractor rates it an +// ERROR, not a warning. See notes.md for provenance and the tiering. +module Corpus +resource Subscription { + acquire Subscribe + release Unsubscribe + kind "subscription token" +} +fn GraphicsConfigurationDialog(systemEvents: int) { + let displaySettings = acquire Subscription(systemEvents); // SystemEvents.DisplaySettingsChanged += handler + // no `release displaySettings;` — the static source pins the dialog forever (OWN001) +} diff --git a/corpus/real-world/screentogif-systemevents-leak/expected-diagnostics.txt b/corpus/real-world/screentogif-systemevents-leak/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/screentogif-systemevents-leak/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/screentogif-systemevents-leak/notes.md b/corpus/real-world/screentogif-systemevents-leak/notes.md new file mode 100644 index 00000000..da010e9f --- /dev/null +++ b/corpus/real-world/screentogif-systemevents-leak/notes.md @@ -0,0 +1,36 @@ +# ScreenToGif — `SystemEvents.DisplaySettingsChanged` subscribed, never detached + +**Found by mining** (P-004 milestone 1, see `docs/notes/real-world-mining.md`), +surfaced once the WPF reference pack was loaded (`OWN_EXTRA_REF_DIRS`). Target: +`NickeManarin/ScreenToGif` @ `27a49c3`, **two** independent occurrences: + +- `ScreenToGif/Windows/Other/GraphicsConfigurationDialog.xaml.cs:35` +- `ScreenToGif/Windows/Other/Troubleshoot.xaml.cs:27` + +**Pattern.** A `Window` subscribes to `Microsoft.Win32.SystemEvents.DisplaySettingsChanged` +with a method-group handler and never unsubscribes. `SystemEvents` is a **static, +process-lifetime** class; its events hold a strong reference to every subscriber +for the life of the process. The window closes but cannot be collected — the +canonical SystemEvents leak that the .NET docs explicitly warn about. + +**What the checker says (real extractor output, WPF profile on):** + +```text +GraphicsConfigurationDialog.xaml.cs:35: error: [OWN001] event + 'SystemEvents.DisplaySettingsChanged' is subscribed (handler + 'SystemEvents_DisplaySettingsChanged') but never unsubscribed — the source keeps + 'GraphicsConfigurationDialog' alive (leak) [resource: subscription token] +``` + +**Why error, not warning (the tiering).** Contrast the `VideoSource` finding next +door (`screentogif-loaded-subscription/`), which the extractor rates a *warning* +because its source is an *injected* field of unknown lifetime. Here the source is a +**static** event, so it *provably* outlives the window — the P-004 severity tiering +classifies it `static` and the leak is a hard **error**, not a "possible leak". The +extractor draws that line from the source's lifetime, exactly as designed. + +**This `case.own`** models the subscription as a generic acquire/release, so it +produces the core's domain-neutral **OWN001** (which already defaults to error — +matching the static-source tier; the warning/error split for injected sources lives +in the C# extractor, above the core). As elsewhere in the corpus, `case.own` is a +hand reduction; `before.cs` / `after.cs` capture the leak and its fix. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e8f5be2d..de25a27a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,6 +80,17 @@ architectural strictness, and the borrow-checker showcase): ### Milestones 1. **WPF leak spike** — find 1–3 real subscription/timer leaks in real code (P-004). + ✔ *Done* — mining real OSS C# surfaced real leaks in `NickeManarin/ScreenToGif`: + a view→view-model subscription (`VideoSource`) and two `SystemEvents` leaks, plus + precise/clean results on disciplined code. The WPF reference unlock + (`OWN_EXTRA_REF_DIRS`) and the self-owned-control precision gap it revealed are + both closed — the exemption now covers `ref`/`out`-built fields (via the class's + own helper) and template + parts, cutting ScreenToGif's WPF-profile findings 123 → 36 (real leaks intact). + The cross-tool oracle confirms the differentiation: CodeQL *and* Infer# (the latter + via a buildable fixture) cover the Dispose/RAII class and flag none of these + subscription leaks — agreeing with Own.NET only on a Dispose leak, never a subscription. + See [docs/notes/real-world-mining.md](notes/real-world-mining.md). 2. **Resource core** — generalise WPF subscriptions + `IDisposable` into one acquire/release/owner/release-region model (P-004 ∪ P-005), so WPF is a *profile*, not a one-off. diff --git a/docs/notes/real-world-mining.md b/docs/notes/real-world-mining.md new file mode 100644 index 00000000..f48915d8 --- /dev/null +++ b/docs/notes/real-world-mining.md @@ -0,0 +1,157 @@ +# Real-world mining run — milestone 1 (WPF leak spike) + +This is the write-up of the first end-to-end run of the analyser over **real, +unmodified OSS C#**, the `ROADMAP.md` milestone 1: *"find 1–3 real +subscription/timer leaks in real code (P-004)."* It is the honest record of what +the tool actually surfaces on real code — signal, precision, and the next gap the +run revealed. + +## How it ran (the loop) + +The Roslyn extractor needs a .NET SDK, which the dev sandbox does not have, and +the supported on-demand path (`mine.yml`, `workflow_dispatch`) could not be +triggered from the automation token. So the run used a small **push-triggered** +miner (`.github/workflows/mine-on-push.yml`) driven by a sentinel file +(`corpus/mine-target.txt`): bump the target, and CI clones the repo, runs +`scripts/mine.sh` (extractor → OwnIR → core, no per-repo build), and echoes the +report to the job log. The findings were then triaged by reading the flagged +`file:line` in a local clone of the target. + +> `mine-on-push.yml` + `corpus/mine-target.txt` (and the analogous `push:` trigger +> + `corpus/oracle-target.txt` on `oracle.yml`) are **dev-loop scaffolding** +> (dev-branch only) — they exist because the token can't `workflow_dispatch`. They +> should not be merged to `main`; the supported paths stay `mine.yml` / `oracle.yml`. + +## What it found + +| Repo (commit) | findings | triage | +|---|---|---| +| `DapperLib/Dapper` @72a54c4 | 1 × OWN001, 1 × OWN050 | TP: `BenchmarkBase._connection` — an undisposed `SqlConnection` field (benchmark project). | +| `JoshClose/CsvHelper` @33970e5 | 43 × OWN001 | TP: undisposed `StreamReader/Writer/CsvDataReader` **locals in tests**; every `using`-scoped local was correctly skipped. | +| `NickeManarin/ScreenToGif` @27a49c3 (WPF profile off) | 8 × OWN001, 210 × OWN050 | **flagship** below + a likely-benign `App`→`AppDomain.UnhandledException` (process-lived subscriber). | +| `NickeManarin/ScreenToGif` @27a49c3 (WPF profile **on**) | 123 × OWN001, 37 × OWN050 | unlock works (OWN050 ↓), but exposes the self-owned-control precision gap below. | +| `NickeManarin/ScreenToGif` @27a49c3 (WPF **on**, after the self-owned fix) | 36 findings, 40 × OWN050 | self-owned-control FPs gone; survivors are real — 2 × `SystemEvents` leaks (error) + the 4 `VideoSource` flagship warnings. | + +**Precision.** Every finding triaged by hand was a *real* undisposed/undetached +resource — no false positives from `using` (the extractor models it as release), +and the severity tiering behaved as designed. The findings cluster where +disposal discipline is intentionally lax (test/benchmark code) — real, but mostly +low-severity in practice. Disciplined shipping libraries came up clean, which is +the *precision* result the methodology wants to see. + +## The flagship finding (milestone 1 ✔) + +`ScreenToGif/Windows/Other/VideoSource.xaml.cs:50-83` — a WPF `Window` subscribes +**four inline lambdas to its view-model's custom events in `Window_Loaded` and +never detaches them** (`Window_Closing` does no `-=`). This is the canonical +view↔view-model lifetime shape that generic IDisposable/CA analyzers miss; it +resolves **without** the WPF reference pack because the events are the app's own +types. The extractor rates it **warning** (the source `_viewModel` is injected, so +its lifetime can't be proven) and notes the lambdas have no `-=` handle — the +honest verdict (it may be a collectable view↔vm cycle, but the +duplicate-handler-on-reload bug is real). Captured as a regression: +`corpus/real-world/screentogif-loaded-subscription/`. + +## The WPF reference unlock (and the gap it revealed) + +The flagship detectors went blind on framework events because the extractor only +loaded the runtime's trusted-platform assemblies — `Button.Click`, +`DispatcherTimer.Tick`, etc. fell out as OWN050. The extractor now also loads +`*.dll` from each dir in the **`OWN_EXTRA_REF_DIRS`** env var (deduped by simple +name against the TPA); the miner materializes the WindowsDesktop ref pack on Linux +(a `net8.0-windows`/`UseWPF` stub restores it via `EnableWindowsTargeting` → 47 ref +dlls) and points the var at it. On ScreenToGif this drove **OWN050 210 → 37**. + +The change is **off by default** (`OWN_EXTRA_REF_DIRS` unset → unchanged +behaviour; the whole existing suite is the guard), so it is a safe, opt-in +capability add. + +Unlocking framework events also jumped **OWN001 8 → 123**, dominated by **false +positives on self-owned controls**: `_thumbBottomLeft.DragDelta +=` (a `Thumb` the +adorner builds via `BuildCorner(ref _thumb, …)`), `_upButton.Click +=` (a template +part from `GetTemplateChild`), etc. A class subscribing to a control it *owns* is a +collectable cycle, not a leak — but the original exemption only recognised a direct +`field = new …`, so indirect (`ref`/`out`) construction and template parts slipped +through. + +**Fixed** — the bug-driven next unit of work the run defined. The self-owned +*subscription* exemption now also folds in `ref`/`out`-populated fields (only when +the populating helper is the class's *own* method — an external `TryResolve(out _bus)` +stays a leak candidate) and +`GetTemplateChild`/`FindName` template parts (kept OUT of the disposal detector's +`constructed` set, so WPF003 still demands disposal of `new`'d fields only). +Re-mining ScreenToGif with the WPF profile confirms it: **123 → 36 findings** (40 +OWN050), the adorner/template-part noise gone while the *real* leaks survive — two +`SystemEvents.DisplaySettingsChanged` subscriptions never detached (flagged +**error**: a static, process-lifetime source is a provable leak — the classic +SystemEvents leak, in `GraphicsConfigurationDialog` / `Troubleshoot`) and the four +`VideoSource` view→view-model lambdas (**warning**). Verified by the `wpf-extractor` +CI job: the `SelfOwnedControlParts` sample asserts both new shapes stay silent. + +Both real leaks are locked as regressions: `corpus/real-world/screentogif-loaded-subscription/` +(VideoSource, warning) and `corpus/real-world/screentogif-systemevents-leak/` +(SystemEvents, error). + +## Cross-tool validation (the oracle) + +"Real leak" was, so far, our own verdict plus manual reasoning. The cross-tool +oracle (`oracle.yml` → `scripts/oracle_compare.py`) settles it: run Own.NET, CodeQL +and Infer# over the *same* commit and diff their leak-class findings. On ScreenToGif +@27a49c3, CodeQL (2.25.6, `security-and-quality`, database-from-source) ran; **Infer# +was skipped** — ScreenToGif is WPF and does not `dotnet build` on the Linux runner +(`NETSDK1100`) — so this is Own.NET vs **CodeQL**. + +Their leak findings are **nearly disjoint** (file overlap: **1**): + +- **Own.NET only** — every subscription/lifetime leak, including the two this run is + about: `GraphicsConfigurationDialog.xaml.cs:35` & `Troubleshoot.xaml.cs:27` + (`SystemEvents.DisplaySettingsChanged`, error) and `VideoSource.xaml.cs:50/67/75/83` + (view→view-model, warning), plus a pile of own-control subscriptions + (`EncoderListViewItem` ×6, `LightWindow` ×5, `SplitButton`, `StatusBand`, …). + **CodeQL flags none of them** — its query set has no "event subscribed, never + unsubscribed" rule. +- **Oracle only — 33** — entirely CodeQL's Dispose/RAII class (`cs/local-not-disposed`: + `OpenFileDialog`/`SaveFileDialog`/`Pen`/`Bitmap`/…, and `cs/dispose-not-called-on-throw`). + Own.NET flags none — a recall gap in the *other* class, and the cause is **method + coverage, not type recognition**: the `--flow-locals` detector skips any method with + an unmodelled construct (`for`/`try`/`switch`), and these disposables live in such + methods (tell: the `StringReader`/`XmlReader` cases are a *recognised* disposable + type, yet still missed). `for` is now lowered too (closing that slice, CI-checked); + the `try`-shaped `dispose-not-called-on-throw` cases are the high-value next step. +- **Agree — 1** (`HttpHelper.cs`). + +So the SystemEvents and VideoSource findings are **differentiated — confirmed by the +oracle, not just argued**: the tools are complementary (Own.NET on subscription/ +lifetime, CodeQL on Dispose/RAII), overlapping on a single file. + +**Infer#, via a buildable fixture.** ScreenToGif can't build on Linux, so to get the +third tool in, a minimal `net8.0` console reproduces both leak classes +(`corpus/fixtures/systemevents-console/`, fed to the oracle via a `local:` target). +All three tools run; the diff is a clean 2×2: + +| `Program.cs` | leak | Own.NET | CodeQL | Infer# | +|---|---|:-:|:-:|:-:| +| `:41` | `new FileStream(…)` never disposed — Dispose/RAII | ✓ | ✓ | ✓ | +| `:20` | `SystemEvents.DisplaySettingsChanged +=` never `-=` — subscription | ✓ | — | — | + +The FileStream leak is **Agree** across all three — the control that proves CodeQL +*and* Infer# actually run and detect resource leaks on this code. The SystemEvents +subscription is **Own.NET only**: **Infer# misses it too.** Both mature oracles cover +the Dispose/RAII class and neither has the subscription-leak class — the +differentiation, nailed with all three tools. + +> Getting a trustworthy diff took fixing two oracle bugs: the comparator dropped +> multi-line / untagged own-check findings (`scripts/mine_report.py` parser drift — +> 38 lines "unparsed", so only 3 of ~36 findings reached the diff), and own-check ran +> without framework refs at `--severity error`, so it never emitted the very findings +> under test (SystemEvents → OWN050; VideoSource → filtered). Both fixed; the +> comparator selftest now covers the multi-line shape, and the oracle's own-check +> materializes the WindowsDesktop refs and runs at `--severity warning`. + +## Reproduce + +Point `mine.yml` (Actions → *mine (corpus)* → Run workflow) at a target; for the +WPF profile, set `OWN_EXTRA_REF_DIRS` to a WindowsDesktop `ref/net8.0` dir (the +miner shows how to materialize it). Read the report in the run summary / artifact. +For the cross-tool diff, run `oracle.yml` the same way — it materializes the +WindowsDesktop refs itself and emits the Own.NET-vs-CodeQL/Infer# agreement report. diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 9619d269..6dc735de 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -140,21 +140,44 @@ left is MemberAccessExpressionSyntax m // P-004 self-owned exemption: is the event SOURCE owned by (and so never longer- // lived than) the subscriber? True for a bare instance event on `this`, or a -// receiver that resolves to a field/local the class constructs (`new`s). Such a -// `source <-> this` reference cycle is GC-collectable, so the subscription is not -// a leak. The receiver is resolved to a SYMBOL (not matched by text), and the -// `constructed` set is AST-based (ObjectCreationExpressionSyntax) — not a regex. -// NOTE: callers must exclude timers — a *running* timer is rooted by the -// dispatcher regardless of who owns the field. +// receiver that resolves to a field/local the class OWNS. "Owns" is the `owned` +// set the caller computes: fields the class constructs directly (`new`), builds +// indirectly through a `ref`/`out` helper, or fetches as one of its own template +// parts. Such a `source <-> this` reference cycle is GC-collectable, so the +// subscription is not a leak. The receiver is resolved to a SYMBOL (not matched by +// text), and `owned` is AST-based — not a regex. NOTE: callers must exclude timers +// — a *running* timer is rooted by the dispatcher regardless of who owns the field. static bool IsSelfOwnedSource(ExpressionSyntax left, IEventSymbol ev, - SemanticModel model, HashSet constructed) + SemanticModel model, HashSet owned) { if (left is not MemberAccessExpressionSyntax m) return !ev.IsStatic; // bare event => an instance event on `this` if (m.Expression is ThisExpressionSyntax) return true; var recv = model.GetSymbolInfo(m.Expression).Symbol; - return (recv is IFieldSymbol or ILocalSymbol) && constructed.Contains(recv.Name); + return (recv is IFieldSymbol or ILocalSymbol) && owned.Contains(recv.Name); +} + +// P-004 (ext): a control fetching one of its OWN template parts — +// `GetTemplateChild("PART_x")` or `[Template.]FindName(...)`, optionally behind a +// cast or `as` — owns the result (it lives inside the control's own template / +// visual tree). AST-only (matched by call name), in the spirit of the rest of the +// file; used to fold template-part fields into the self-owned exemption. +static bool IsTemplatePartFetch(ExpressionSyntax? expr) +{ + expr = expr switch + { + CastExpressionSyntax c => c.Expression, + BinaryExpressionSyntax b when b.IsKind(SyntaxKind.AsExpression) => b.Left, + _ => expr, + }; + return expr is InvocationExpressionSyntax inv + && (inv.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text, + IdentifierNameSyntax id => id.Identifier.Text, + _ => null, + }) is "GetTemplateChild" or "FindName"; } // P-004 static-handler exemption: a `+= StaticMethod` stores a delegate whose @@ -312,16 +335,30 @@ static bool LowerFlowStmt(StatementSyntax st, HashSet tracked, List