From c8bafba7c93d5f59ff7a3aeda4985943b5eb71bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:26:31 +0000 Subject: [PATCH 01/19] ci(mine): echo report.md + findings.txt to the job log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mine workflow only surfaced its report to the run summary and an artifact. The eval loop (API/agent) reads job logs, not summaries or artifacts, so also echo report.md and findings.txt to stdout (grouped) — no behavior change to the mining itself, just makes the result readable from the run log. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine.yml | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 226a2afddcc6a843131ee4275c335187396f4009 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:32:42 +0000 Subject: [PATCH 02/19] ci(mine): push-triggered miner driven by a sentinel target file Adds .github/workflows/mine-on-push.yml + corpus/mine-target.txt so the corpus eval loop runs without manual workflow_dispatch (which the agent token can't do). On a push that changes the sentinel, the workflow allowlist-validates the target (owner/repo or https URL), runs scripts/mine.sh on it, and echoes the report to the job log. Dev-branch scoped; remove before any merge to main. First target: DapperLib/Dapper (baseline signal/noise read). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine-on-push.yml | 82 ++++++++++++++++++++++++++++++ corpus/mine-target.txt | 5 ++ 2 files changed, 87 insertions(+) create mode 100644 .github/workflows/mine-on-push.yml create mode 100644 corpus/mine-target.txt diff --git a/.github/workflows/mine-on-push.yml b/.github/workflows/mine-on-push.yml new file mode 100644 index 00000000..d17a4a7b --- /dev/null +++ b/.github/workflows/mine-on-push.yml @@ -0,0 +1,82 @@ +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: 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/corpus/mine-target.txt b/corpus/mine-target.txt new file mode 100644 index 00000000..04577a4a --- /dev/null +++ b/corpus/mine-target.txt @@ -0,0 +1,5 @@ +# 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. +DapperLib/Dapper From 51683b6aadc543c3dfdf66767059c68d63a7039f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:51:12 +0000 Subject: [PATCH 03/19] ci(mine): point the sentinel at JoshClose/CsvHelper Baseline mine on DapperLib/Dapper validated the loop: 1 verified true-positive (BenchmarkBase._connection, an undisposed SqlConnection field) + 1 OWN050. Hunt continues on a shipping-code library for a higher-impact field/subscription leak. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- corpus/mine-target.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus/mine-target.txt b/corpus/mine-target.txt index 04577a4a..250178b6 100644 --- a/corpus/mine-target.txt +++ b/corpus/mine-target.txt @@ -2,4 +2,4 @@ # 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. -DapperLib/Dapper +JoshClose/CsvHelper From 5ef565244b6317cf2b9268f523a76c8a359b95b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 14:04:38 +0000 Subject: [PATCH 04/19] ci(mine): probe Linux WPF reference assemblies + target ScreenToGif To unlock the flagship WPF subscription/timer detectors on real apps, the extractor must resolve WPF framework types (it currently loads only the runtime TPA -> Button.Click/DispatcherTimer.Tick fall out as OWN050). This adds a TEMP probe step that restores a net8.0-windows/UseWPF stub (EnableWindowsTargeting) and reports where PresentationFramework.dll lands on the Linux runner, so the extractor change can point at the right ref dir. Sentinel -> a real WPF app (NickeManarin/ScreenToGif) for a no-refs baseline in the same run. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine-on-push.yml | 29 +++++++++++++++++++++++++++++ corpus/mine-target.txt | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mine-on-push.yml b/.github/workflows/mine-on-push.yml index d17a4a7b..e51559ba 100644 --- a/.github/workflows/mine-on-push.yml +++ b/.github/workflows/mine-on-push.yml @@ -30,6 +30,35 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: "8.0.x" + - name: Probe WPF reference assemblies (TEMP — for the WPF unlock) + continue-on-error: true + run: | + root="${DOTNET_ROOT:-/usr/share/dotnet}" + echo "DOTNET_ROOT=$root" + echo "::group::Already in the SDK packs?" + find "$root/packs" -ipath '*WindowsDesktop.App.Ref*' -name 'PresentationFramework.dll' 2>/dev/null | head || echo "(none in SDK packs)" + echo "::endgroup::" + echo "::group::Restore a net8.0-windows/UseWPF stub (EnableWindowsTargeting)" + tmp=$(mktemp -d) + printf '%s\n' \ + '' \ + ' ' \ + ' net8.0-windows' \ + ' true' \ + ' true' \ + ' ' \ + '' > "$tmp/probe.csproj" + dotnet restore "$tmp/probe.csproj" 2>&1 | tail -8 || echo "RESTORE FAILED" + echo "::endgroup::" + echo "::group::Where did the WPF ref assemblies land?" + d=$(find "$root/packs/Microsoft.WindowsDesktop.App.Ref" -type d -name 'net8.0' 2>/dev/null | head -1) + [ -z "$d" ] && d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | head -1) + echo "WPF ref dir: ${d:-NONE FOUND}" + if [ -n "$d" ]; then + echo "count of ref dlls: $(find "$d" -name '*.dll' | wc -l)" + ls "$d" | grep -iE 'Presentation|WindowsBase|System.Xaml|System.Windows' | head -20 + fi + echo "::endgroup::" - name: Read & validate the target id: target run: | diff --git a/corpus/mine-target.txt b/corpus/mine-target.txt index 250178b6..1d39ce05 100644 --- a/corpus/mine-target.txt +++ b/corpus/mine-target.txt @@ -2,4 +2,4 @@ # 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. -JoshClose/CsvHelper +NickeManarin/ScreenToGif From 45a340d2cc2d8fe06d78b5e969266eddaf5471c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 14:16:52 +0000 Subject: [PATCH 05/19] feat(extractor): load extra reference assemblies via OWN_EXTRA_REF_DIRS (WPF unlock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractor built its reference set purely from the runtime TPA, so WPF framework events/timers (Button.Click, DispatcherTimer.Tick) couldn't bind and fell out as OWN050 on real WPF apps — blinding the flagship subscription/timer detectors. Now it also loads *.dll from each dir in OWN_EXTRA_REF_DIRS (colon- separated), deduped by simple name against the TPA so System.* isn't double- referenced. Unset => unchanged behaviour (the whole existing suite is the guard). mine-on-push materializes the WindowsDesktop ref pack on Linux (a net8.0-windows /UseWPF stub restores it via EnableWindowsTargeting -> 47 ref dlls) and exports the dir as OWN_EXTRA_REF_DIRS. Re-mining ScreenToGif with the pack loaded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine-on-push.yml | 30 ++++++++----------- corpus/mine-target.txt | 1 + frontend/roslyn/OwnSharp.Extractor/Program.cs | 21 +++++++++++-- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.github/workflows/mine-on-push.yml b/.github/workflows/mine-on-push.yml index e51559ba..5f738c46 100644 --- a/.github/workflows/mine-on-push.yml +++ b/.github/workflows/mine-on-push.yml @@ -30,15 +30,12 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: "8.0.x" - - name: Probe WPF reference assemblies (TEMP — for the WPF unlock) - continue-on-error: true + - name: Materialize WPF reference assemblies (WPF profile) run: | - root="${DOTNET_ROOT:-/usr/share/dotnet}" - echo "DOTNET_ROOT=$root" - echo "::group::Already in the SDK packs?" - find "$root/packs" -ipath '*WindowsDesktop.App.Ref*' -name 'PresentationFramework.dll' 2>/dev/null | head || echo "(none in SDK packs)" - echo "::endgroup::" - echo "::group::Restore a net8.0-windows/UseWPF stub (EnableWindowsTargeting)" + # 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' \ '' \ @@ -47,18 +44,15 @@ jobs: ' true' \ ' true' \ ' ' \ - '' > "$tmp/probe.csproj" - dotnet restore "$tmp/probe.csproj" 2>&1 | tail -8 || echo "RESTORE FAILED" - echo "::endgroup::" - echo "::group::Where did the WPF ref assemblies land?" - d=$(find "$root/packs/Microsoft.WindowsDesktop.App.Ref" -type d -name 'net8.0' 2>/dev/null | head -1) - [ -z "$d" ] && d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | head -1) - echo "WPF ref dir: ${d:-NONE FOUND}" + '' > "$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) if [ -n "$d" ]; then - echo "count of ref dlls: $(find "$d" -name '*.dll' | wc -l)" - ls "$d" | grep -iE 'Presentation|WindowsBase|System.Xaml|System.Windows' | head -20 + 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 - echo "::endgroup::" - name: Read & validate the target id: target run: | diff --git a/corpus/mine-target.txt b/corpus/mine-target.txt index 1d39ce05..6af75728 100644 --- a/corpus/mine-target.txt +++ b/corpus/mine-target.txt @@ -2,4 +2,5 @@ # 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). NickeManarin/ScreenToGif diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 9619d269..d7567f31 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -415,11 +415,28 @@ t is "IDisposable" or "IAsyncDisposable" or "CancellationTokenSource" // in-project types and BCL events; external types (WPF/DevExpress) stay // unresolved and are surfaced as OWN050 "unchecked", never guessed as leaks. // Error-tolerant: compile diagnostics are irrelevant — we only read symbols. -var references = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") +var tpa = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) .ToList(); +var refNames = new HashSet(tpa.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase); +var references = tpa.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); +// P-004 WPF profile: widen the reference set with assemblies named by the +// OWN_EXTRA_REF_DIRS env var (colon-separated dirs) — e.g. the WindowsDesktop ref +// pack — so framework events/timers (Button.Click, DispatcherTimer.Tick) resolve to +// real symbols instead of surfacing as OWN050 on a WPF app. Additive and best- +// effort: unset => unchanged behaviour; a DLL whose simple name a TPA reference +// already provides is skipped so System.* is not double-referenced from two packs. +foreach (var dir in (Environment.GetEnvironmentVariable("OWN_EXTRA_REF_DIRS") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) +{ + if (!Directory.Exists(dir)) continue; + var added = 0; + foreach (var dll in Directory.EnumerateFiles(dir, "*.dll")) + if (refNames.Add(Path.GetFileName(dll))) + { references.Add(MetadataReference.CreateFromFile(dll)); added++; } + Console.Error.WriteLine($"extractor: +{added} extra references from {dir}"); +} var compilation = CSharpCompilation.Create( "own", parsed.Select(p => p.tree), references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); From 7a2e879786aa1ef0cca552efb4d336d7ef7b67ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:10:16 +0000 Subject: [PATCH 06/19] =?UTF-8?q?docs(p-004):=20bank=20milestone-1=20minin?= =?UTF-8?q?g=20run=20=E2=80=94=20real=20finding=20as=20regression=20+=20wr?= =?UTF-8?q?ite-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1 ("find 1-3 real subscription/timer leaks in real code") is met: mining real OSS C# surfaced a real WPF view->view-model subscription leak in ScreenToGif (VideoSource.xaml.cs — four inline-lambda handlers wired in Window_Loaded, never detached). Capture it and write up the run. - corpus/real-world/screentogif-loaded-subscription/: the finding reduced to a tested regression (case.own -> OWN001, before/after .cs, provenance in notes). - docs/notes/real-world-mining.md: the run write-up — the autonomous push-miner loop, the 4-repo findings table + precision verdict, the flagship finding, the WPF reference unlock (OWN_EXTRA_REF_DIRS: OWN050 210->37), and the next gap it revealed (self-owned controls built via ref-params / template parts). - docs/ROADMAP.md: mark milestone 1 progressed, link the write-up. Full suite green (corpus 3/3). --- .../screentogif-loaded-subscription/after.cs | 34 ++++++++ .../screentogif-loaded-subscription/before.cs | 32 ++++++++ .../screentogif-loaded-subscription/case.own | 16 ++++ .../expected-diagnostics.txt | 1 + .../screentogif-loaded-subscription/notes.md | 40 +++++++++ docs/ROADMAP.md | 5 ++ docs/notes/real-world-mining.md | 82 +++++++++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 corpus/real-world/screentogif-loaded-subscription/after.cs create mode 100644 corpus/real-world/screentogif-loaded-subscription/before.cs create mode 100644 corpus/real-world/screentogif-loaded-subscription/case.own create mode 100644 corpus/real-world/screentogif-loaded-subscription/expected-diagnostics.txt create mode 100644 corpus/real-world/screentogif-loaded-subscription/notes.md create mode 100644 docs/notes/real-world-mining.md 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/docs/ROADMAP.md b/docs/ROADMAP.md index e8f5be2d..f72d2a7d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,6 +80,11 @@ architectural strictness, and the borrow-checker showcase): ### Milestones 1. **WPF leak spike** — find 1–3 real subscription/timer leaks in real code (P-004). + ✔ *First run done* — mining real OSS C# surfaced a real view→view-model + subscription leak in `NickeManarin/ScreenToGif` (and the precise/clean results + on disciplined code). See [docs/notes/real-world-mining.md](notes/real-world-mining.md) + for the run, the WPF reference unlock (`OWN_EXTRA_REF_DIRS`), and the next gap + it revealed (self-owned controls built indirectly / as template parts). 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..2fa3e0cf --- /dev/null +++ b/docs/notes/real-world-mining.md @@ -0,0 +1,82 @@ +# 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` are **dev-loop scaffolding** +> (dev-branch only) — they exist because the token can't dispatch `mine.yml`. +> They should not be merged to `main`; the supported path stays `mine.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. | + +**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. + +But unlocking framework events also jumped **OWN001 8 → 123**, and the new +findings are dominated by **self-owned controls**: `_thumbBottomLeft.DragDelta +=` +(a `Thumb` the adorner constructs via `BuildCorner(ref _thumb, …)`), +`_upButton.Click +=` (a template part fetched with `GetTemplateChild`), etc. A +class subscribing to a control it *owns* is a collectable cycle — not a leak — but +the existing self-owned exemption only recognises fields built by a direct +`field = new …`, so indirect construction (`ref`/`out` params) and template parts +slip through. **Next step:** extend the self-owned exemption to those two shapes +before the WPF profile is on by default. This is textbook bug-driven expansion — +the real-world run defined the next unit of work. + +## 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. From 2ea4d9326f04570b80d2c73f0cd5aa6c529d2a51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:32:57 +0000 Subject: [PATCH 07/19] feat(extractor): self-owned subscription exemption now covers ref/out-built fields and template parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WPF mining run (docs/notes/real-world-mining.md) showed that loading the WPF reference pack jumps OWN001 from 8 to 123, dominated by FALSE positives on self-owned controls: a class subscribing to a control it owns is a collectable cycle, not a leak. The existing exemption only recognised fields built by a direct `field = new ...`, so two real ownership shapes slipped through: * indirect construction — `BuildCorner(ref _thumb, ...)`: a helper the class calls populates the field (the adorner pattern); * template parts — `_part = GetTemplateChild("PART_x") as T` / FindName: a control owns the parts of its own template. Fold both into a separate `selfOwned` set used by the subscription exemption only. It is deliberately kept OUT of `constructed`, so the WPF003 disposal detector keeps demanding disposal of `new`'d fields only (you do not dispose a borrowed-by-ref value or a template part). Detection is AST-based — the ref/out argument keyword, and a GetTemplateChild/FindName call name behind an optional cast / `as` — in the spirit of the rest of the extractor. Verification: new sample SelfOwnedControlParts.cs exercises both shapes; the ci.yml wpf-extractor job asserts it produces no finding, alongside the existing self-owned silence checks. --- .github/workflows/ci.yml | 8 +++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 62 ++++++++++++++++--- .../roslyn/samples/SelfOwnedControlParts.cs | 51 +++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 frontend/roslyn/samples/SelfOwnedControlParts.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8da46a6c..6dc1b8df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,7 @@ jobs: frontend/roslyn/samples/PooledBufferSample.cs \ frontend/roslyn/samples/LocalDisposableSample.cs \ frontend/roslyn/samples/SelfOwnedViewModel.cs \ + frontend/roslyn/samples/SelfOwnedControlParts.cs \ frontend/roslyn/samples/StaticHandlerViewModel.cs \ frontend/roslyn/samples/SampleTypes.cs \ -o "$RUNNER_TEMP/facts.json" @@ -215,6 +216,13 @@ 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 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 diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index d7567f31..c39c4da7 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 @@ -486,6 +509,27 @@ or ImplicitObjectCreationExpressionSyntax && FieldName(a.Left) is { } fn) constructed.Add(fn); + // P-004 (extended) self-owned set for the SUBSCRIPTION exemption ONLY. A + // field can be owned without a direct `_f = new ...`; fold in two more + // shapes the WPF mining run surfaced. Kept OUT of `constructed` so the + // WPF003 disposal detector keeps demanding disposal of `new`'d fields only + // (you don't dispose a borrowed-by-ref value or a template part): + // * ref/out construction — `BuildCorner(ref _thumb, ...)`: a helper this + // class calls populates the field, so the class owns it; + // * template parts — `_part = GetTemplateChild("PART_x") as T`: a control + // owns the parts of its own template (collectable part<->control cycle). + var selfOwned = new HashSet(constructed); + foreach (var arg in cls.DescendantNodes().OfType()) + if ((arg.RefKindKeyword.IsKind(SyntaxKind.RefKeyword) + || arg.RefKindKeyword.IsKind(SyntaxKind.OutKeyword)) + && FieldName(arg.Expression) is { } rf) + selfOwned.Add(rf); + foreach (var a in assigns) + if (a.IsKind(SyntaxKind.SimpleAssignmentExpression) + && FieldName(a.Left) is { } tf + && IsTemplatePartFetch(a.Right)) + selfOwned.Add(tf); + var subs = new List(); foreach (var a in assigns) { @@ -507,7 +551,7 @@ or ImplicitObjectCreationExpressionSyntax // constructs) — the source<->this cycle is GC-collectable; // - static handler — a static method has a null delegate target, // so no instance is retained and nothing can leak. - if (!isTimer && (IsSelfOwnedSource(a.Left, ev, model, constructed) + if (!isTimer && (IsSelfOwnedSource(a.Left, ev, model, selfOwned) || IsStaticHandler(a.Right, model))) continue; // P-004 tiering: a local-variable source is method-bounded — it diff --git a/frontend/roslyn/samples/SelfOwnedControlParts.cs b/frontend/roslyn/samples/SelfOwnedControlParts.cs new file mode 100644 index 00000000..2dc120c7 --- /dev/null +++ b/frontend/roslyn/samples/SelfOwnedControlParts.cs @@ -0,0 +1,51 @@ +using System; + +// P-004 self-owned exemption (EXTENDED) — both subscriptions below must stay +// SILENT. A class can OWN its event source without a direct `_f = new ...`; these +// are the two shapes the WPF mining run on ScreenToGif surfaced, each a +// GC-collectable source<->this cycle rather than a leak: +// * `_thumb` is constructed INDIRECTLY — handed by `ref` to a helper that `new`s +// it (the adorner `BuildCorner(ref _thumb, ...)` shape); +// * `_upButton` is one of this control's OWN template parts, fetched with +// `GetTemplateChild(...)` (the templated-control shape). +// Contrast CustomerViewModel, whose source is an injected bus (a warning). Timers +// remain the exception (a running timer is dispatcher-rooted) — see TimerViewModel. +// +// Stand-in types (Tier A: no WPF reference set) mirror the real surface: a Thumb +// with DragDelta, a RepeatButton with Click, and a control with the protected +// GetTemplateChild(string) lookup. Namespaced so they cannot collide with the +// other samples compiled alongside this file. +namespace OwnSamples.SelfOwnedParts +{ + public sealed class SelfOwnedControlParts : TemplatedControlStub + { + private Thumb _thumb = null!; // built indirectly, through a `ref` helper + private RepeatButton? _upButton; // fetched as one of our own template parts + + public SelfOwnedControlParts() + { + BuildCorner(ref _thumb); + _thumb.DragDelta += OnDrag; // self-owned (ref-constructed) -> not a leak + } + + public override void OnApplyTemplate() + { + _upButton = GetTemplateChild("PART_Up") as RepeatButton; + _upButton!.Click += OnUp; // self-owned (template part) -> not a leak + } + + private static void BuildCorner(ref Thumb t) => t = new Thumb(); + + private void OnDrag(object? sender, EventArgs e) { } + private void OnUp(object? sender, EventArgs e) { } + } + + public sealed class Thumb { public event EventHandler? DragDelta; } + public sealed class RepeatButton { public event EventHandler? Click; } + + public abstract class TemplatedControlStub + { + protected object? GetTemplateChild(string name) => null; + public virtual void OnApplyTemplate() { } + } +} From 70af82027dfd7011e46c39141bf35da0dc54cc69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:33:12 +0000 Subject: [PATCH 08/19] chore(dev): re-mine ScreenToGif (WPF profile) to confirm the self-owned-control fix Dev-loop only (mine-on-push, dev-branch scaffolding). Re-run the WPF-profile mine over ScreenToGif now that the self-owned exemption covers ref/out-built fields and GetTemplateChild template parts, to confirm the OWN001 self-owned-control false positives drop from the 123 baseline. --- corpus/mine-target.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/corpus/mine-target.txt b/corpus/mine-target.txt index 6af75728..cff56dee 100644 --- a/corpus/mine-target.txt +++ b/corpus/mine-target.txt @@ -3,4 +3,7 @@ # 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 From ab8f39ce3b7bc328409d9f130a8431b6012055e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:41:06 +0000 Subject: [PATCH 09/19] docs(p-004): record the verified self-owned-control fix result (123 -> 36 on ScreenToGif) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-mining ScreenToGif with the WPF profile after the exemption extension confirms OWN001 drops from the 123 baseline to 36 findings: the adorner / template-part false positives are gone, while the real leaks survive — two SystemEvents.DisplaySettingsChanged subscriptions (flagged error, the classic static-source leak, in GraphicsConfigurationDialog / Troubleshoot) and the four VideoSource view->view-model lambdas (warning). Close the documented "next gap" and note the new SystemEvents finding; update milestone 1. --- docs/ROADMAP.md | 12 +++++++----- docs/notes/real-world-mining.md | 30 ++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f72d2a7d..e13549f2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,11 +80,13 @@ architectural strictness, and the borrow-checker showcase): ### Milestones 1. **WPF leak spike** — find 1–3 real subscription/timer leaks in real code (P-004). - ✔ *First run done* — mining real OSS C# surfaced a real view→view-model - subscription leak in `NickeManarin/ScreenToGif` (and the precise/clean results - on disciplined code). See [docs/notes/real-world-mining.md](notes/real-world-mining.md) - for the run, the WPF reference unlock (`OWN_EXTRA_REF_DIRS`), and the next gap - it revealed (self-owned controls built indirectly / as template parts). + ✔ *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 and template + parts, cutting ScreenToGif's WPF-profile findings 123 → 36 (real leaks intact). + 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 index 2fa3e0cf..73c74671 100644 --- a/docs/notes/real-world-mining.md +++ b/docs/notes/real-world-mining.md @@ -29,6 +29,7 @@ report to the job log. The findings were then triaged by reading the flagged | `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), @@ -64,16 +65,25 @@ 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. -But unlocking framework events also jumped **OWN001 8 → 123**, and the new -findings are dominated by **self-owned controls**: `_thumbBottomLeft.DragDelta +=` -(a `Thumb` the adorner constructs via `BuildCorner(ref _thumb, …)`), -`_upButton.Click +=` (a template part fetched with `GetTemplateChild`), etc. A -class subscribing to a control it *owns* is a collectable cycle — not a leak — but -the existing self-owned exemption only recognises fields built by a direct -`field = new …`, so indirect construction (`ref`/`out` params) and template parts -slip through. **Next step:** extend the self-owned exemption to those two shapes -before the WPF profile is on by default. This is textbook bug-driven expansion — -the real-world run defined the next unit of work. +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 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. ## Reproduce From a722ecbe171ea16074053c8d647bb63be935a24f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:08:39 +0000 Subject: [PATCH 10/19] test(corpus): lock the ScreenToGif SystemEvents leak as a real-world regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WPF-profile mine surfaced a second real leak: GraphicsConfigurationDialog and Troubleshoot each subscribe to Microsoft.Win32.SystemEvents.DisplaySettingsChanged (a static, process-lifetime source) and never unsubscribe — the classic SystemEvents leak. Because the source is static it provably outlives the window, so the extractor rates it an ERROR (vs VideoSource's injected -> warning). Add corpus/real-world/screentogif-systemevents-leak/ (case.own -> OWN001, before/after .cs, provenance + tiering in notes.md); the corpus test now runs 4/4. Point the write-up at both locked regressions. --- .../screentogif-systemevents-leak/after.cs | 23 ++++++++++++ .../screentogif-systemevents-leak/before.cs | 25 +++++++++++++ .../screentogif-systemevents-leak/case.own | 17 +++++++++ .../expected-diagnostics.txt | 1 + .../screentogif-systemevents-leak/notes.md | 36 +++++++++++++++++++ docs/notes/real-world-mining.md | 4 +++ 6 files changed, 106 insertions(+) create mode 100644 corpus/real-world/screentogif-systemevents-leak/after.cs create mode 100644 corpus/real-world/screentogif-systemevents-leak/before.cs create mode 100644 corpus/real-world/screentogif-systemevents-leak/case.own create mode 100644 corpus/real-world/screentogif-systemevents-leak/expected-diagnostics.txt create mode 100644 corpus/real-world/screentogif-systemevents-leak/notes.md 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/notes/real-world-mining.md b/docs/notes/real-world-mining.md index 73c74671..6bcd789f 100644 --- a/docs/notes/real-world-mining.md +++ b/docs/notes/real-world-mining.md @@ -85,6 +85,10 @@ SystemEvents leak, in `GraphicsConfigurationDialog` / `Troubleshoot`) and the fo `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). + ## Reproduce Point `mine.yml` (Actions → *mine (corpus)* → Run workflow) at a target; for the From 7e6ca46ecf39d8dc3a83fc2554ba952b0bd48699 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:25:56 +0000 Subject: [PATCH 11/19] chore(dev): push-triggerable oracle + run it on ScreenToGif (SystemEvents cross-check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation token can't workflow_dispatch oracle.yml (403), so add a push trigger gated on a sentinel (corpus/oracle-target.txt), mirroring mine-on-push. A "Resolve target" step feeds repo/ref/paths/build/include_tests from inputs (on dispatch) or the sentinel (on push); every downstream step now reads steps.t.outputs.*, so both triggers share the same tested CodeQL/Infer#/diff pipeline. The dispatch path is unchanged. Dev-branch scaffolding — remove before merging to main. Sentinel points at NickeManarin/ScreenToGif @27a49c3 to cross-check the SystemEvents.DisplaySettingsChanged finding: does CodeQL (and Infer#, if the WPF target builds on Linux) also flag it, or is it Own.NET-only (differentiated)? --- .github/workflows/oracle.yml | 52 +++++++++++++++++++++++++++++++----- corpus/oracle-target.txt | 11 ++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 corpus/oracle-target.txt diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 2d393f79..7d2dbb61 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,14 +65,44 @@ 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 or an https URL — never an odd scheme. + if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$repo" =~ ^https://[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: | case "$REPO" in http://*|https://*|git@*) url="$REPO" ;; @@ -81,7 +119,7 @@ jobs: # Own.NET — no build needed; scans .cs directly. - name: Own.NET own-check env: - PATHS: ${{ inputs.paths }} + PATHS: ${{ steps.t.outputs.paths }} run: | scan="target"; [[ -n "$PATHS" ]] && scan="target/$PATHS" set +e @@ -115,8 +153,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 +200,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/oracle-target.txt b/corpus/oracle-target.txt new file mode 100644 index 00000000..dff7ffd0 --- /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" or an https URL. +# Optional lines: ref=, paths=, build=, include_tests=. +# Bump this file (commit + push) to run the cross-tool oracle. Dev-branch only. +# +# Cross-tool validation of the SystemEvents finding: does CodeQL (and Infer#, if the +# WPF target builds on Linux) also flag the ScreenToGif +# SystemEvents.DisplaySettingsChanged leak, or is it an Own.NET-only (differentiated) +# find? Pinned to the commit we mined. +NickeManarin/ScreenToGif +ref=27a49c3 From 1beb01da77e6bd307e66ed48304e7d81b15c7ed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:33:08 +0000 Subject: [PATCH 12/19] fix(oracle): blobless clone so an abbreviated SHA ref resolves (re-run ScreenToGif) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oracle's clone did `git clone --depth 1` then `git fetch --depth 1 origin $REF` — which fails for an abbreviated SHA (servers reject unadvertised/short SHAs in a want request), so the ScreenToGif cross-check died at the Clone step. Use a blobless clone (`--filter=blob:none`, full commit/ref graph, blobs on demand) and `checkout --detach "$REF"`, which resolves any ref — branch, tag, or short SHA — locally. Keep the fast `--depth 1` path when no ref is given. Bump the sentinel to re-run on NickeManarin/ScreenToGif @27a49c3. --- .github/workflows/oracle.yml | 12 ++++++++---- corpus/oracle-target.txt | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 7d2dbb61..a37d4603 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -108,11 +108,15 @@ jobs: http://*|https://*|git@*) url="$REPO" ;; *) url="https://github.com/${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 + # 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" diff --git a/corpus/oracle-target.txt b/corpus/oracle-target.txt index dff7ffd0..08830026 100644 --- a/corpus/oracle-target.txt +++ b/corpus/oracle-target.txt @@ -6,6 +6,7 @@ # Cross-tool validation of the SystemEvents finding: does CodeQL (and Infer#, if the # WPF target builds on Linux) also flag the ScreenToGif # SystemEvents.DisplaySettingsChanged leak, or is it an Own.NET-only (differentiated) -# find? Pinned to the commit we mined. +# find? Pinned to the commit we mined. (Re-run after the blobless-clone fix, so the +# abbreviated SHA below resolves at checkout.) NickeManarin/ScreenToGif ref=27a49c3 From 85cddedf17f1613018c5619516d9f8b766afb6ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:50:34 +0000 Subject: [PATCH 13/19] fix(oracle): parse multi-line/untagged own-check findings + give own-check framework refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ScreenToGif oracle run mis-reported "Own.NET = 3" with "38 own-check line(s) did not parse" — two bugs, so the diff's Own.NET side was unusable (the SystemEvents / VideoSource findings never showed): 1. Parser drift (scripts/mine_report.py, shared with the miner): _LINE *required* a trailing `[resource: kind]` tag and matched single lines only, so findings without the tag — and multi-line findings (an inline lambda handler echoed across lines, the VideoSource shape) — failed to parse. Make the tag optional (msg non-greedy) and fold continuation lines into the current finding's message, recovering a late `[resource:]` kind from the last line. Add a multi-line / no-tag regression to the oracle_compare selftest (16 -> 19 checks). 2. Config (.github/workflows/oracle.yml): own-check ran at the default --severity error and without OWN_EXTRA_REF_DIRS, so SystemEvents (unresolved -> OWN050) and the warning-tier leaks (VideoSource) were never emitted. Materialize the WindowsDesktop ref pack and run own-check at --severity warning, so framework events resolve and warning-tier subscription leaks are included — putting own-check on equal footing with CodeQL (which resolves types from source). Bump the sentinel to re-run on ScreenToGif @27a49c3 for a clean Own.NET-vs-CodeQL diff. --- .github/workflows/oracle.yml | 36 ++++++++++++++++++++++++++++++++++-- corpus/oracle-target.txt | 5 +++-- scripts/mine_report.py | 36 ++++++++++++++++++++++++++++-------- scripts/oracle_compare.py | 20 +++++++++++++++++++- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index a37d4603..112b1197 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -120,14 +120,46 @@ jobs: fi echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV" - # Own.NET — no build needed; scans .cs directly. + # 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: | + 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) + 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 + + # 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: ${{ 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 diff --git a/corpus/oracle-target.txt b/corpus/oracle-target.txt index 08830026..17a4ceec 100644 --- a/corpus/oracle-target.txt +++ b/corpus/oracle-target.txt @@ -6,7 +6,8 @@ # Cross-tool validation of the SystemEvents finding: does CodeQL (and Infer#, if the # WPF target builds on Linux) also flag the ScreenToGif # SystemEvents.DisplaySettingsChanged leak, or is it an Own.NET-only (differentiated) -# find? Pinned to the commit we mined. (Re-run after the blobless-clone fix, so the -# abbreviated SHA below resolves at checkout.) +# find? Pinned to the commit we mined. (Re-run after the comparator parser fix + +# own-check framework-refs / --severity warning, so our SystemEvents & VideoSource +# OWN001 findings are emitted, parsed, and land in "Own.NET only" vs CodeQL.) NickeManarin/ScreenToGif ref=27a49c3 diff --git a/scripts/mine_report.py b/scripts/mine_report.py index 5ea72f66..0a3852a3 100644 --- a/scripts/mine_report.py +++ b/scripts/mine_report.py @@ -46,38 +46,58 @@ def _load_titles() -> dict[str, str]: TITLES = _load_titles() -# own-check human line: ":: : [] [resource: ]" +# own-check human header: ":: : [] [ [resource: ]]". +# The trailing `[resource: kind]` tag is OPTIONAL and, for a multi-line finding (an +# inline lambda handler echoed across lines), lands on the LAST line — so the header +# must match with or without it, and `msg` is non-greedy so a tag on the header line +# is split out rather than swallowed. _LINE = re.compile( r"^(?P.+?):(?P\d+): (?Perror|warning): " - r"\[(?P[A-Z]+\d+)\] (?P.*) \[resource: (?P[^\]]*)\]\s*$" + r"\[(?P[A-Z]+\d+)\] (?P.*?)" + r"(?: \[resource: (?P[^\]]*)\])?\s*$" ) +# the trailing `[resource: kind]` tag wherever it lands — recovers the kind from the +# last line of a multi-line finding. +_RESOURCE_TAIL = re.compile(r"\[resource: (?P[^\]]*)\]\s*$") # the core's trailing chatter ("N findings.", "... ok — no ...") is not a finding. _CHATTER = re.compile(r"\b\d+ (finding|error)s?\b|: ok | no ownership| no subscription") def parse(text: str) -> tuple[list[dict[str, Any]], int]: """Parse own-check human output into finding dicts; return (findings, unparsed). - Build chatter shouldn't reach here (own-check sends it to stderr), but if a - stray line does, count it rather than silently dropping it.""" + A finding may span several lines — an inline lambda handler body is echoed across + lines, with the trailing `[resource: kind]` tag (when present) on the last — so a + non-header line extends the current finding's message and recovers a late kind, + rather than counting as drift. Build chatter shouldn't reach here (own-check sends + it to stderr); a stray line that does is counted, not silently dropped.""" findings: list[dict[str, Any]] = [] unparsed = 0 + cur: dict[str, Any] | None = None for raw in text.splitlines(): line = raw.rstrip() if not line: continue m = _LINE.match(line) if m is None: - if not _CHATTER.search(line): + if _CHATTER.search(line): + continue + if cur is not None: # multi-line finding body + cur["message"] += "\n" + line + tail = _RESOURCE_TAIL.search(line) + if tail and not cur["kind"]: + cur["kind"] = tail["kind"] + else: unparsed += 1 continue - findings.append({ + cur = { "file": m["file"], "line": int(m["line"]), "severity": m["sev"], "code": m["code"], "message": m["msg"], - "kind": m["kind"], - }) + "kind": m["kind"] or "", + } + findings.append(cur) return findings, unparsed diff --git a/scripts/oracle_compare.py b/scripts/oracle_compare.py index d66ef5eb..7df59496 100644 --- a/scripts/oracle_compare.py +++ b/scripts/oracle_compare.py @@ -492,6 +492,24 @@ def _selftest() -> int: fails.append(f"parser drift not surfaced: expected 1 unparsed, got {drift}") if "warning:" not in render_md(r, "o/r", "abc123", ["infersharp"], 3, drift): fails.append("unparsed warning not rendered in header") + # multi-line findings (an inline lambda handler echoed across lines) parse as ONE + # finding, with the kind recovered from the last line; a missing/optional + # `[resource:]` tag is fine. Neither shape counts as drift — this is the own-check + # format that silently broke the ScreenToGif oracle run (38 lines "unparsed"). + ml_txt = ( + "src/V.xaml.cs:50: warning: [OWN001] event 'x' subscribed (handler '(_, e) =>\n" + " {\n" + " Do();\n" + " }') but never unsubscribed [resource: subscription token]\n" + "src/N.cs:7: error: [OWN001] field 'f' is never released\n" + ) + ml, ml_drift = build_own(ml_txt, []) + if len(ml) != 2: + fails.append(f"multi-line/untagged: expected 2 findings, got {len(ml)}") + if ml_drift != 0: + fails.append(f"multi-line finding miscounted as drift: {ml_drift}") + if not any(f.rule == "OWN001" and f.fkey == "v.xaml.cs" for f in ml): + fails.append("multi-line finding lost its file/line/code") # Infer#'s Pulse engine emits PULSE_RESOURCE_LEAK (not RESOURCE_LEAK) — it must # still classify as a leak, not out-of-scope context. pulse_sarif = json.dumps({"runs": [{"results": [ @@ -513,7 +531,7 @@ def _selftest() -> int: if "product code only" not in render_md(r, "o/r", "abc", ["codeql"], 3, 0, 0, True): fails.append("scope note must render when exclude-tests mode is on (even at 0)") - total = 16 + total = 19 for f in fails: print(f"ORACLE SELFTEST FAIL: {f}") print(f"oracle_compare selftest: {total - len(fails)}/{total} checks passed") From 1246b56814ec125012394c68159c17b9fb5f0c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:03:27 +0000 Subject: [PATCH 14/19] docs(p-004): record the cross-tool oracle result (CodeQL misses the subscription leaks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oracle run on ScreenToGif @27a49c3 (Own.NET vs CodeQL; Infer# skipped — WPF won't build on Linux) shows the two tools' leak findings are nearly disjoint: SystemEvents (error) and VideoSource (warning) land in "Own.NET only" alongside a pile of own-control subscriptions, CodeQL's 33 leak findings are entirely Dispose/RAII (cs/local-not-disposed, cs/dispose-not-called-on-throw), and the leak files overlap on exactly one. So the differentiation is confirmed by the oracle, not just argued — the tools are complementary. Note the honest caveats (Infer# skipped; our Dispose-class recall gap) and the two oracle bugs fixed to get a trustworthy diff. --- docs/ROADMAP.md | 2 ++ docs/notes/real-world-mining.md | 46 ++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e13549f2..49d3da0e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -86,6 +86,8 @@ architectural strictness, and the borrow-checker showcase): (`OWN_EXTRA_REF_DIRS`) and the self-owned-control precision gap it revealed are both closed — the exemption now covers `ref`/`out`-built fields and template parts, cutting ScreenToGif's WPF-profile findings 123 → 36 (real leaks intact). + The cross-tool oracle confirms the differentiation: CodeQL's leak queries are + Dispose/RAII-only and flag none of these subscription leaks (leak-file overlap: 1). 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 diff --git a/docs/notes/real-world-mining.md b/docs/notes/real-world-mining.md index 6bcd789f..2d3153fc 100644 --- a/docs/notes/real-world-mining.md +++ b/docs/notes/real-world-mining.md @@ -17,9 +17,10 @@ miner (`.github/workflows/mine-on-push.yml`) driven by a sentinel file 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` are **dev-loop scaffolding** -> (dev-branch only) — they exist because the token can't dispatch `mine.yml`. -> They should not be merged to `main`; the supported path stays `mine.yml`. +> `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 @@ -89,8 +90,47 @@ Both real leaks are locked as regressions: `corpus/real-world/screentogif-loaded (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 of these — a real recall gap in the *other* class (local + IDisposables), a candidate for a later D1 pass. +- **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. For an Infer# data +point too, a minimal **non-WPF** repro (it builds on Linux) is the clean follow-up. + +> 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. From 0d31e10183fa61dcdf11813806684b43eb286161 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:10:07 +0000 Subject: [PATCH 15/19] test(oracle): Linux-buildable SystemEvents fixture so all three tools run (incl. Infer#) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScreenToGif is WPF and won't dotnet build on the Linux oracle runner, so Infer# was skipped — the cross-tool diff was Own.NET vs CodeQL only. Add a minimal net8.0 console fixture (corpus/fixtures/systemevents-console/) with two leak classes: * SystemEvents.DisplaySettingsChanged subscribed, never -= (subscription/lifetime) * a new FileStream local never disposed (Dispose/RAII — the control) and teach the oracle a `local:` target (copied into target/ instead of cloned) so the fixture builds and Infer# actually runs. Expected 2x2: the FileStream leak agrees across all three; the SystemEvents leak is Own.NET-only — the final nail in the differentiation (and a check of whether Infer# catches the subscription class). Point the sentinel at the fixture to run it. --- .github/workflows/oracle.yml | 42 ++++++++++------- .../fixtures/systemevents-console/Program.cs | 45 +++++++++++++++++++ .../fixtures/systemevents-console/README.md | 29 ++++++++++++ .../SystemEventsLeak.csproj | 19 ++++++++ corpus/oracle-target.txt | 20 ++++----- 5 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 corpus/fixtures/systemevents-console/Program.cs create mode 100644 corpus/fixtures/systemevents-console/README.md create mode 100644 corpus/fixtures/systemevents-console/SystemEventsLeak.csproj diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 112b1197..9b38cd1d 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -87,8 +87,9 @@ jobs: 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 or an https URL — never an odd scheme. - if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$repo" =~ ^https://[A-Za-z0-9./_-]+$ ]]; then + # 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"; @@ -104,21 +105,32 @@ jobs: REPO: ${{ steps.t.outputs.repo }} REF: ${{ steps.t.outputs.ref }} run: | - case "$REPO" in - http://*|https://*|git@*) url="$REPO" ;; - *) url="https://github.com/${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" + 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 - git clone --depth 1 --quiet "$url" target + case "$REPO" in + http://*|https://*|git@*) url="$REPO" ;; + *) url="https://github.com/${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 - echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV" # 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 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/oracle-target.txt b/corpus/oracle-target.txt index 17a4ceec..2557be31 100644 --- a/corpus/oracle-target.txt +++ b/corpus/oracle-target.txt @@ -1,13 +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" or an https URL. -# Optional lines: ref=, paths=, build=, include_tests=. -# Bump this file (commit + push) to run the cross-tool oracle. Dev-branch only. +# 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 validation of the SystemEvents finding: does CodeQL (and Infer#, if the -# WPF target builds on Linux) also flag the ScreenToGif -# SystemEvents.DisplaySettingsChanged leak, or is it an Own.NET-only (differentiated) -# find? Pinned to the commit we mined. (Re-run after the comparator parser fix + -# own-check framework-refs / --severity warning, so our SystemEvents & VideoSource -# OWN001 findings are emitted, parsed, and land in "Own.NET only" vs CodeQL.) -NickeManarin/ScreenToGif -ref=27a49c3 +# 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 From 1f467a98439e6d33b729428a9345e0a1a8993c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:15:30 +0000 Subject: [PATCH 16/19] =?UTF-8?q?docs(p-004):=20the=20cross-tool=202x2=20?= =?UTF-8?q?=E2=80=94=20Infer#=20also=20misses=20the=20subscription=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux-buildable fixture (corpus/fixtures/systemevents-console/) got all three tools running through the oracle. Clean 2x2: the FileStream Dispose leak (Program.cs:41) is Agree across Own.NET + CodeQL + Infer# (the control — both oracles run and catch the RAII class), while the SystemEvents subscription leak (Program.cs:20) is Own.NET only — Infer# misses it too. Both mature oracles cover Dispose/RAII and neither has the subscription-leak class. Record the result in the write-up and ROADMAP. --- docs/ROADMAP.md | 5 +++-- docs/notes/real-world-mining.md | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 49d3da0e..c5b328e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -86,8 +86,9 @@ architectural strictness, and the borrow-checker showcase): (`OWN_EXTRA_REF_DIRS`) and the self-owned-control precision gap it revealed are both closed — the exemption now covers `ref`/`out`-built fields and template parts, cutting ScreenToGif's WPF-profile findings 123 → 36 (real leaks intact). - The cross-tool oracle confirms the differentiation: CodeQL's leak queries are - Dispose/RAII-only and flag none of these subscription leaks (leak-file overlap: 1). + 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 diff --git a/docs/notes/real-world-mining.md b/docs/notes/real-world-mining.md index 2d3153fc..609a61d4 100644 --- a/docs/notes/real-world-mining.md +++ b/docs/notes/real-world-mining.md @@ -116,8 +116,23 @@ Their leak findings are **nearly disjoint** (file overlap: **1**): 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. For an Infer# data -point too, a minimal **non-WPF** repro (it builds on Linux) is the clean follow-up. +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 — From 80dd30ed3303b203fda32585184787480a63de6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:29:17 +0000 Subject: [PATCH 17/19] feat(extractor): lower `for` loops in the flow detector (close the for-loop recall slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Digging into the oracle's "Oracle only 33" on ScreenToGif (CodeQL Dispose-class findings Own.NET misses): it's a method-coverage gap, NOT type recognition. The --flow-locals detector bails on any method with an unmodelled construct, and the undisposed locals sit in those methods — the tell is the StringReader/XmlReader cases (a recognised disposable type) being missed only because their method had one. `for (init; cond; incr) body` has the same 0+-iteration shape as while/foreach, so lower its body to a `while` flow op (init/cond/incr opaque; the tracked locals are the body's — a resource in the for-INITIALIZER is not a method-body local, so never a tracked candidate -> sound, no new FPs). FlowLocalsSample gains a ForLeak case (per-iteration leak -> flagged) and HasLoop's `looped` stays silent (now balanced rather than skipped); the --stats coverage invariant still holds. `do`/try/switch remain unmodelled — `try` (the dispose-not-called-on-throw class) is the next step. --- .github/workflows/ci.yml | 14 +++++++------ docs/notes/real-world-mining.md | 8 ++++++-- frontend/roslyn/OwnSharp.Extractor/Program.cs | 20 ++++++++++++++++--- frontend/roslyn/samples/FlowLocalsSample.cs | 18 ++++++++++++++--- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dc1b8df..330909d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,21 +249,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/docs/notes/real-world-mining.md b/docs/notes/real-world-mining.md index 609a61d4..c4faf2c1 100644 --- a/docs/notes/real-world-mining.md +++ b/docs/notes/real-world-mining.md @@ -110,8 +110,12 @@ Their leak findings are **nearly disjoint** (file overlap: **1**): 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 of these — a real recall gap in the *other* class (local - IDisposables), a candidate for a later D1 pass. + 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 diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index c39c4da7..e1167921 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -335,16 +335,30 @@ static bool LowerFlowStmt(StatementSyntax st, HashSet tracked, List