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();
if (fes.Statement is null || !LowerFlowStmt(fes.Statement, tracked, bodyNodes))
return false;
nodes.Add(new { op = "while", line = LineOf(fes), body = bodyNodes });
return true;
}
+ case ForStatementSyntax fors:
+ {
+ // `for (init; cond; incr) body` runs its body 0+ times — the same
+ // ownership shape as `while`/`foreach`. init/cond/incr are opaque (we
+ // model control flow, not values); the tracked locals are the ones the
+ // BODY declares. A resource declared in the `for` *initializer* is not a
+ // method-body local, so it is never a tracked candidate — no soundness
+ // concern, just a separate (rare) recall gap. `do`/try/switch stay below.
+ var bodyNodes = new List