Skip to content

fix(extractor): recognize .Close() as a field release (receiver-sco… #524

fix(extractor): recognize .Close() as a field release (receiver-sco…

fix(extractor): recognize .Close() as a field release (receiver-sco… #524

Workflow file for this run

name: CI
# Least privilege: every job only reads the repo (no job pushes or needs write).
# Action SHA-pinning / persist-credentials hardening is deliberately deferred to
# a Dependabot/hardening pass — see README "где оно жульничает" item #7.
permissions:
contents: read
on:
push:
branches: ["**"]
pull_request:
workflow_dispatch:
jobs:
# Quality gate: ruff (style/bugs) on the whole tree, and mypy --strict on the
# ownlang package (tests are dynamic/fuzzer code, covered by ruff only). These
# are the "tighten the screws on Python" guard rails — see README.
lint:
name: lint (ruff + mypy --strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install linters
run: pip install "ruff==0.15.8" "mypy==1.19.1"
- name: ruff
run: ruff check .
- name: mypy --strict (ownlang)
run: mypy
# The evaluation scripts (corpus miner, cross-tool oracle diff, metamorphic
# analyzer tester) carry embedded fixtures / sweep the .own corpus; run their
# selftests here so the parsers/aggregators and the robustness invariants stay
# honest on every push, not only on workflow_dispatch.
- name: script selftests (miner + oracle + metamorphic + benchmark)
run: |
python scripts/mine_report.py --selftest
python scripts/oracle_compare.py --selftest
python scripts/metamorphic.py --selftest
python scripts/metamorphic_facts.py --selftest
python scripts/benchmark.py --selftest
tests:
name: tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The PoC needs 3.11+ (see README). Run the floor and current releases.
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# Zero-dependency project: nothing to install. The suite runs the
# analyzer cases, the golden ArrayPool lowering, the codegen content
# assertions, and the property fuzzer (fixed seed) in one entrypoint.
- name: Run test suite
run: python tests/run_tests.py
# A heavier, non-blocking fuzz pass so a flake-free regression that only
# shows up on other random draws still gets surfaced on every push.
fuzz-extended:
name: extended codegen fuzz
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Property fuzz (50k draws, rotating seed)
run: python tests/test_codegen_props.py 50000 ${{ github.run_number }}
# Prove the lowering is real: take the generated C# and put it through the
# actual .NET compiler (the PoC sandbox has no SDK, so this is the only place
# the golden example is genuinely compiled and run, not "verified by
# construction").
dotnet-golden:
name: golden C# compiles & runs (.NET)
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: Check the emitted method is still in sync with the golden host
run: python examples/golden_arraypool/verify_emit.py
- name: Compile & run the generated C# with the real compiler
run: |
dotnet new console -o "$RUNNER_TEMP/golden_app"
cp examples/golden_arraypool/Program.cs "$RUNNER_TEMP/golden_app/Program.cs"
dotnet run --project "$RUNNER_TEMP/golden_app"
# P-001: prove the C# leak pipeline end-to-end on real C# — the Roslyn
# extractor turns sample .cs into OwnIR facts, and the core surfaces the
# subscription leak at its C# location (and stays silent on the disposed one).
wpf-extractor:
name: C# leak extractor (Roslyn) -> OwnIR -> core
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: Extract OwnIR facts from sample C#
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/CustomerViewModel.cs \
frontend/roslyn/samples/LambdaHandlerViewModel.cs \
frontend/roslyn/samples/AliasedSourceViewModel.cs \
frontend/roslyn/samples/OrdersViewModel.cs \
frontend/roslyn/samples/TimerViewModel.cs \
frontend/roslyn/samples/DisposableFieldViewModel.cs \
frontend/roslyn/samples/MessengerViewModel.cs \
frontend/roslyn/samples/PooledBufferSample.cs \
frontend/roslyn/samples/LocalDisposableSample.cs \
frontend/roslyn/samples/SelfOwnedViewModel.cs \
frontend/roslyn/samples/SelfOwnedControlParts.cs \
frontend/roslyn/samples/ExternalRefSubscription.cs \
frontend/roslyn/samples/StaticHandlerViewModel.cs \
frontend/roslyn/samples/StaticEventEscapeViewModel.cs \
frontend/roslyn/samples/WhenAnyValueViewModel.cs \
frontend/roslyn/samples/DiCaptiveSample.cs \
frontend/roslyn/samples/SampleTypes.cs \
frontend/roslyn/samples/PipeFieldsSample.cs \
frontend/roslyn/samples/AppLifetimeSample.cs \
frontend/roslyn/samples/ViewOwnsVmSample.xaml.cs \
frontend/roslyn/samples/InjectedDcViewSample.xaml.cs \
frontend/roslyn/samples/ResolvedDisposableSample.cs \
frontend/roslyn/samples/FieldReleaseSample.cs \
frontend/roslyn/samples/StaticClassEscapeSample.cs \
frontend/roslyn/samples/EventSourceCountersSample.cs \
frontend/roslyn/samples/AppDomainShutdownSample.cs \
frontend/roslyn/samples/AliasDisposeSample.cs \
frontend/roslyn/samples/CloseReleaseSample.cs \
-o "$RUNNER_TEMP/facts.json"
cat "$RUNNER_TEMP/facts.json"
- name: Check facts through the core
run: |
out=$(python -m ownlang ownir "$RUNNER_TEMP/facts.json" || true)
echo "$out"
echo "$out" | grep -q "OWN001" \
|| { echo "FAIL: expected OWN001"; exit 1; }
# P-004 tiering: CustomerViewModel subscribes to an INJECTED bus (a ctor
# param of unknown lifetime). We cannot prove it outlives the view model,
# so the leak is reported at WARNING level (an honest "possible leak"),
# not a hard error — until lifetime/ownership modelling lands.
echo "$out" | grep -qE "CustomerViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected CustomerViewModel as a WARNING (injected source)"; exit 1; }
echo "$out" | grep -q "injected dependency whose lifetime is unknown" \
|| { echo "FAIL: expected the injected-source wording"; exit 1; }
if echo "$out" | grep -q "OrdersViewModel.cs"; then
echo "FAIL: disposed subscription wrongly reported"; exit 1
fi
# Mined FP regression (Pipelines.Sockets.Unofficial): System.IO.Pipelines PipeReader/PipeWriter
# END WITH Reader/Writer but are NOT IDisposable (they finish via Complete(), not Dispose()), so
# an undisposed PipeReader/PipeWriter FIELD must NOT be flagged as a leak —
# IsNonDisposableReaderWriter excludes them from the field-disposable name heuristic.
if echo "$out" | grep -q "PipeFieldsSample.cs"; then
echo "FAIL: PipeReader/PipeWriter field wrongly reported as an undisposed-disposable leak"; exit 1
fi
# a lambda handler has no stored delegate, so it can NEVER be `-=`'d — the
# finding says so. (Same injected source as Customer -> also a warning.)
echo "$out" | grep -qE "LambdaHandlerViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected the lambda-handler subscription leak (warning)"; exit 1; }
echo "$out" | grep -q "inline lambda it has no '-=' handle" \
|| { echo "FAIL: expected the lambda no-handle wording"; exit 1; }
# P-004 provenance: a local that ALIASES an injected source (var src =
# _bus) is NOT method-bounded — it must warn, not be silently dropped. A
# local the scope CONSTRUCTS (var owned = new Calc()) IS bounded -> silent.
echo "$out" | grep -qE "AliasedSourceViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: aliased-injected local should warn, not be dropped"; exit 1; }
if echo "$out" | grep -q "owned.Changed"; then
echo "FAIL: a locally-constructed publisher must be dropped"; exit 1
fi
# WPF002: the started, never-stopped timer leaks with a [resource: timer]
# tag; the timer stopped in Dispose stays silent.
echo "$out" | grep -q "TimerViewModel.cs" \
|| { echo "FAIL: expected the TimerViewModel timer leak"; exit 1; }
echo "$out" | grep -q "resource: timer" \
|| { echo "FAIL: expected a [resource: timer] tag"; exit 1; }
if echo "$out" | grep -q "CleanTimerViewModel"; then
echo "FAIL: stopped timer wrongly reported"; exit 1
fi
# WPF003: the IDisposable field the class new's but never disposes leaks
# with a [resource: disposable field] tag; the one disposed in Dispose
# stays silent.
echo "$out" | grep -q "DisposableFieldViewModel.cs" \
|| { echo "FAIL: expected the ReportViewModel field leak"; exit 1; }
echo "$out" | grep -q "resource: disposable field" \
|| { echo "FAIL: expected a [resource: disposable field] tag"; exit 1; }
if echo "$out" | grep -q "CleanReportViewModel"; then
echo "FAIL: disposed field wrongly reported"; exit 1
fi
# a static IDisposable field is a process-lifetime singleton (Dapper's
# DisposedReader.Instance) — never an owned leak, so it stays silent.
if echo "$out" | grep -q "SharedTokenHolder"; then
echo "FAIL: a static singleton IDisposable field was wrongly reported"; exit 1
fi
# P-004 resolve-aware disposability (mined: ImageSharp Vp8BitWriter/JpegBitReader):
# a field whose type NAME ends in Writer/Reader/Stream but is NOT IDisposable (and
# RESOLVES) must NOT be flagged — IsOwnedDisposableType asks the real interface.
if echo "$out" | grep -q "EncoderWithNonDisposableWriter"; then
echo "FAIL: a resolved non-IDisposable Writer/Reader field was wrongly flagged"; exit 1
fi
# control: resolved IDisposable fields (MemoryStream / CancellationTokenSource) the
# class new's but never disposes must STILL warn — real detection intact. (CodeRabbit:
# tie the assertion to OWN001 + the disposable-field resource, not just the class name.
# Severity-agnostic on purpose — the disposable-field leak renders as error, not warning.)
echo "$out" | grep -qE "ResolvedDisposableSample\.cs:[0-9]+:.*\[OWN001\].*resource: disposable field" \
|| { echo "FAIL: expected the OWN001 disposable-field finding on the resolved IDisposable control"; exit 1; }
echo "$out" | grep -q "HolderWithRealDisposable" \
|| { echo "FAIL: the resolved IDisposable control (MemoryStream/CTS) must be flagged by owner name"; exit 1; }
# dispose-optional control (Codex): Task / DataTable ARE IDisposable but disposal is
# optional (IsDisposeOptional) — a new'd, undisposed field of these must stay SILENT.
if echo "$out" | grep -q "HolderWithDisposeOptional"; then
echo "FAIL: a dispose-optional (Task/DataTable) field was wrongly flagged"; exit 1
fi
# field release recognition (mined: ImageSharp). #2 null-conditional dispose
# `field?.Dispose()` must be recognized -> silent; the undisposed control still warns.
if echo "$out" | grep -q "DisposesViaConditional"; then
echo "FAIL: a field disposed via null-conditional field?.Dispose() was wrongly flagged"; exit 1
fi
echo "$out" | grep -q "NeverDisposesField" \
|| { echo "FAIL: an undisposed IDisposable field control must still warn"; exit 1; }
# #3 a pooled FIELD released cross-member (ctor rent + Dispose Return) must be silent;
# the rented-never-returned control still warns.
if echo "$out" | grep -q "pooled buffer 'returnedBuf'"; then
echo "FAIL: a pooled field returned in Dispose was wrongly flagged"; exit 1
fi
echo "$out" | grep -q "pooled buffer 'leakedBuf'" \
|| { echo "FAIL: a pooled field rented but never returned must still warn"; exit 1; }
# field disposed through a local ALIAS (mined: Npgsql NpgsqlDataSource): `var cts = _cts;
# cts.Dispose();` (and the `this._f` / `cts?.Dispose()` shapes) releases the field -> the
# aliased fields must be SILENT.
if echo "$out" | grep -qE "'_aliased'|'_aliasedQ'"; then
echo "FAIL: a field disposed through a local alias was wrongly reported as undisposed"; exit 1
fi
# controls: an alias that is never disposed, and an alias REBOUND to a new object, must
# both STILL leak (the recognition needs an actual dispose on an un-reassigned alias).
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_neverDisposed'" \
|| { echo "FAIL: a field aliased but never disposed must still warn"; exit 1; }
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_rebound'" \
|| { echo "FAIL: a field whose alias was rebound to a new object must still warn"; exit 1; }
# Codex control: an alias rebound through a ref/out ARGUMENT must still leak.
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_refRebound'" \
|| { echo "FAIL: a field whose alias was rebound via a ref/out argument must still warn"; exit 1; }
# Codex/CodeRabbit control: aliases are symbol-scoped, not name-keyed — an unrelated
# same-named local disposed in another method must NOT credit the field, so it still leaks.
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_scopedLeak'" \
|| { echo "FAIL: a same-named local in another scope must not be miscredited (symbol-scoped aliases)"; exit 1; }
# a field released via `.Close()` (direct and null-conditional) must be SILENT — mirrors the
# local detector's Dispose/Close/DisposeAsync set (mined: Npgsql ReplicationConnection._npgsqlConnection).
if echo "$out" | grep -qE "'_closedConn'|'_closedConnQ'"; then
echo "FAIL: a field released via .Close() was wrongly reported as undisposed"; exit 1
fi
# control: a connection-like field NEITHER closed NOR disposed must STILL warn.
echo "$out" | grep -qE "CloseReleaseSample\.cs:[0-9]+:.*\[OWN001\].*'_leakedConn'" \
|| { echo "FAIL: a field that is never closed/disposed must still warn (Close-as-release stays scoped to an actual Close call)"; exit 1; }
# Codex/CodeRabbit control: Close() credits THIS instance's field only — closing ANOTHER instance
# of the same class's same-named field must NOT suppress this object's leak (ThisFieldName, not a
# receiver-stripping name match that a same-class ContainingType check would also miss).
echo "$out" | grep -qE "CloseReleaseSample\.cs:[0-9]+:.*\[OWN001\].*'_xconn'" \
|| { echo "FAIL: other-instance .Close() must not credit this field (receiver-scoped to this/alias)"; exit 1; }
# WPF004: an ignored `X.Subscribe(...)` result leaks; the captured+
# disposed one stays silent. "ignored" is unique to the WPF004 message.
echo "$out" | grep -q "MessengerViewModel.cs" \
|| { echo "FAIL: expected the InboxViewModel ignored-Subscribe leak"; exit 1; }
echo "$out" | grep -q "is ignored" \
|| { echo "FAIL: expected the ignored-Subscribe message"; exit 1; }
if echo "$out" | grep -q "CleanInboxViewModel"; then
echo "FAIL: captured+disposed subscription wrongly reported"; exit 1
fi
# POOL001: a Rent'd-but-never-Return'd buffer leaks; the rent+return
# (finally) one stays silent.
echo "$out" | grep -q "pooled buffer 'leaky'" \
|| { echo "FAIL: expected the rented-not-returned buffer leak"; exit 1; }
if echo "$out" | grep -q "pooled buffer 'ok'"; then
echo "FAIL: returned buffer wrongly reported"; exit 1
fi
# P-005 D1: a `new`'d local IDisposable never disposed leaks; a `using`
# one and a returned (transferred) one stay silent.
echo "$out" | grep -q "local IDisposable 'leaky'" \
|| { echo "FAIL: expected the undisposed-local leak"; exit 1; }
echo "$out" | grep -q "LocalDisposableSample.cs" \
|| { echo "FAIL: expected LocalDisposableSample.cs in the local-disposable finding"; exit 1; }
echo "$out" | grep -q "resource: disposable]" \
|| { echo "FAIL: expected a [resource: disposable] tag"; exit 1; }
if echo "$out" | grep -qE "'guarded'|'moved'"; then
echo "FAIL: using/returned local wrongly reported"; exit 1
fi
# P-004 self-owned exemption: a subscription whose source is a field the
# class constructs (owns) is a GC-collectable cycle, not a leak — silent.
if echo "$out" | grep -q "SelfOwnedViewModel.cs"; then
echo "FAIL: a self-owned subscription was wrongly reported"; exit 1
fi
# P-004 self-owned (extended): a field built indirectly via a `ref`/`out`
# helper, or fetched as one of the control's own template parts
# (GetTemplateChild), is owned just like a `new`'d field — both
# subscriptions in SelfOwnedControlParts are collectable cycles -> silent.
if echo "$out" | grep -q "SelfOwnedControlParts.cs"; then
echo "FAIL: a self-owned (ref-built / template-part) subscription was wrongly reported"; exit 1
fi
# P-004 (ref/out narrowing, Codex P2): a field populated by an EXTERNAL
# class's ref method (not this class's own helper) is NOT self-owned — the
# subscription must still be reported, not silently suppressed.
echo "$out" | grep -qE "ExternalRefSubscription\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected OWN001 on the external-ref subscription (must not be exempted)"; exit 1; }
# P-004 self-WhenAnyValue classifier (docs/notes/self-whenany-precision.md):
# `this.WhenAnyValue(p => p.SelfProp[, q => q.Other]).Subscribe` over the
# component's OWN single-hop properties is a collectable self-cycle ->
# silent; a nested path through an INJECTED object, or a combinator that
# mixes in an EXTERNAL observable, stays a flagged leak (OWN001).
echo "$out" | grep -q "x.Svc.Name" \
|| { echo "FAIL: nested-path WhenAnyValue (injected Svc) must leak"; exit 1; }
echo "$out" | grep -q "CombineLatest" \
|| { echo "FAIL: combinator WhenAnyValue (external observable) must leak"; exit 1; }
# the multi-arg single-hop self chain must be SILENCED (the fix): `x => x.B`
# appears only in that chain, so it must not surface anywhere.
if echo "$out" | grep -q "x => x.B"; then
echo "FAIL: multi-arg single-hop self WhenAnyValue must be silenced"; exit 1
fi
# exactly two WhenAnyValueViewModel leaks (nested + combinator) — the three
# self-rooted chains produce nothing.
n=$(echo "$out" | grep -cE "WhenAnyValueViewModel\.cs:[0-9]+:.*\[OWN001\]")
[ "$n" = "2" ] \
|| { echo "FAIL: expected exactly 2 WhenAnyValueViewModel leaks, got $n"; exit 1; }
# P-004 static-handler exemption: a static-method handler has a null
# delegate target — no instance retained, so not a leak — silent.
if echo "$out" | grep -q "StaticHandlerViewModel.cs"; then
echo "FAIL: a static-handler subscription was wrongly reported"; exit 1
fi
# P-004 WPF005 region escape: an INSTANCE handler subscribed to a
# process-lived STATIC event (Calc.GlobalPing) with no `-=` is a region
# escape, NOT a token leak. The extractor lowers the static-source `+=` to
# a `capture` fact and the core's region engine reports OWN014 (the
# view-model is promoted to process lifetime), an error — proving real C#
# static-event subscriptions reach the region core, not only OWN001.
echo "$out" | grep -qE "StaticEventEscapeViewModel\.cs:[0-9]+: error: \[OWN014\]" \
|| { echo "FAIL: expected OWN014 region escape on the static-event instance subscription"; exit 1; }
echo "$out" | grep -q "region escape" \
|| { echo "FAIL: expected the region-escape wording on the static-event capture"; exit 1; }
# P-004 process-lifetime AppDomain-event exemption (mined: Npgsql PoolManager): a
# NON-CAPTURING handler on a process-host AppDomain event (ProcessExit/DomainUnload/
# UnhandledException/FirstChanceException) is a shutdown/diagnostics hook meant to live
# for the process -> NOT a region escape -> silent. (Scoped to ShutdownCleanup, the
# exempt class — CapturingShutdownSubscriber in the same file MUST still raise OWN014.)
if echo "$out" | grep -qE "OWN014.*'ShutdownCleanup'"; then
echo "FAIL: ShutdownCleanup's non-capturing AppDomain subscriptions were wrongly reported as OWN014"; exit 1
fi
# scope guards: a lambda on a NON-AppDomain static event still escapes; and an AppDomain
# handler that CAPTURES instance state is still pinned to the process -> still OWN014 (Codex).
echo "$out" | grep -qE "OWN014.*NonAppDomainSubscriber" \
|| { echo "FAIL: a lambda on a non-AppDomain static event must still raise OWN014 (exemption stays scoped)"; exit 1; }
echo "$out" | grep -qE "OWN014.*CapturingShutdownSubscriber" \
|| { echo "FAIL: an instance-capturing AppDomain handler must still raise OWN014 (exemption is non-capturing only)"; exit 1; }
# the unsubscribed variant (a matching `-=`, released capture) is mitigated
# -> silent. Must NOT be reported.
if echo "$out" | grep -q "CleanStaticEventViewModel"; then
echo "FAIL: an unsubscribed (released) static-event subscription was wrongly reported"; exit 1
fi
# P-004 robust static-handler exemption (mined: ImageSharp MemoryAllocatorValidator): a
# static-METHOD handler on a static event stores a null-target delegate -> no instance is
# retained -> OWN014 must NOT fire, even when the method-group symbol surfaces as a member
# group (now resolved via CandidateSymbols). (StaticEventEscapeViewModel above proves an
# INSTANCE handler on the same static event still escapes, so this stays scoped.)
if echo "$out" | grep -q "StaticAllocationCounter"; then
echo "FAIL: a static-method handler on a static event was wrongly reported as a region escape"; exit 1
fi
# P-004 EventSource diagnostic-counter exemption (mined: Npgsql NpgsqlEventSource): a
# DiagnosticCounter (EventCounter / PollingCounter / Incrementing{Event,Polling}Counter)
# built with `this` is registered to the parent EventSource and shares its process
# lifetime -> idiomatically never field-disposed -> must NOT be flagged as an undisposed leak.
if echo "$out" | grep -qE "'_bytesPerSecond'|'_totalBytes'|'_commandDuration'|'_totalCommands'"; then
echo "FAIL: an EventSource-owned DiagnosticCounter field was wrongly reported as an undisposed leak"; exit 1
fi
# scope control (Codex): the exemption keys off the DiagnosticCounter type handed to
# `this`, NOT the EventSource class — so a plain owned IDisposable field in the same
# EventSource (`_scratch`) must STILL raise the OWN001 disposable-field leak.
echo "$out" | grep -qE "EventSourceCountersSample\.cs:[0-9]+:.*\[OWN001\].*'_scratch'" \
|| { echo "FAIL: a non-counter owned IDisposable field in an EventSource must still warn (exemption stays counter-type-scoped)"; exit 1; }
# declared-type control (Codex): a field DECLARED as a plain IDisposable that is ALSO
# assigned a counter once (`_mixed = new EventCounter(..., this)`) must STILL leak its
# earlier `new MemoryStream()` — the exemption requires the DECLARED field type to be a
# DiagnosticCounter, so a name-only skip cannot hide the non-counter resource.
echo "$out" | grep -qE "EventSourceCountersSample\.cs:[0-9]+:.*\[OWN001\].*'_mixed'" \
|| { echo "FAIL: a field declared as a non-counter IDisposable that is later assigned a counter must still leak (exemption requires the DECLARED type to be a DiagnosticCounter)"; exit 1; }
# P-004 process-lived-subscriber exemption (mined: ScreenToGif App +
# Translator): the WPF `App` singleton hooking the process-lived
# AppDomain.UnhandledException promotes nothing, so the static-source region
# escape (OWN014) must NOT fire — for both the name-based (`partial class
# App`) and base-based (`: Application`) shapes.
if echo "$out" | grep -q "AppLifetimeSample.cs"; then
echo "FAIL: a process-lived App static-event subscription was wrongly reported (OWN014 FP)"; exit 1
fi
# P-004 WPF MVVM ownership (mined: ScreenToGif VideoSource): a view that
# CONSTRUCTS its view-model in its own XAML (`<X.DataContext><VM/>` — read from
# the sibling .xaml) owns it, so a field assigned from `DataContext` is
# self-owned and subscribing to its events is a collectable cycle -> SILENT.
if echo "$out" | grep -q "ViewOwnsVmSample"; then
echo "FAIL: a view that owns its VM via its own XAML DataContext was wrongly reported"; exit 1
fi
# negative control: a view whose XAML BINDS its DataContext (`<Binding/>`) does
# NOT own the VM (it may be externally supplied), so the subscription must
# still WARN — proving the gate keys off proven construction, not every cast.
echo "$out" | grep -qE "InjectedDcViewSample\.xaml\.cs:[0-9]+: warning: \[OWN001\].*injected dependency whose lifetime is unknown" \
|| { echo "FAIL: a bound (unowned) DataContext subscription must still warn with the injected-source wording"; exit 1; }
# P-006 DI001 (captive dependency): the registration + constructor graph
# extracted from DiCaptiveSample.cs feeds ownlang/di.py. A singleton that
# captures a scoped service — directly, transitively through a transient,
# or through an interface registration — is flagged at the registration
# site; a singleton->singleton edge and the clean registrations stay silent.
echo "$out" | grep -q "DI001" \
|| { echo "FAIL: expected DI001 captive-dependency findings"; exit 1; }
echo "$out" | grep -q "singleton 'EmailSender' captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected the direct captive (singleton EmailSender -> scoped AppDbContext)"; exit 1; }
# the transitive capture must thread through the transient UnitOfWork.
echo "$out" | grep -q "ReportService -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive captive path via the transient UnitOfWork"; exit 1; }
# the interface registration (AddScoped<IRepo, Repo>) must map so the
# singleton consuming IRepo is caught.
echo "$out" | grep -q "singleton 'CacheService' captures scoped service 'IRepo'" \
|| { echo "FAIL: expected the interface-registration captive (CacheService -> IRepo)"; exit 1; }
# C# 12 primary-constructor injection (deps on the class declaration, not a
# ctor member) must be read too.
echo "$out" | grep -q "singleton 'PrimaryCtorService' captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected the primary-constructor captive (PrimaryCtorService -> AppDbContext)"; exit 1; }
echo "$out" | grep -q "DiCaptiveSample.cs" \
|| { echo "FAIL: expected the DI001 findings at the DiCaptiveSample.cs registration site"; exit 1; }
# NOT captive: singleton->singleton (Metrics->Clock), and PublicCtorOnly —
# DI resolves its public parameterless ctor, so the wider PRIVATE ctor's
# scoped dependency is never used. None of these may be flagged.
if echo "$out" | grep -qE "captures scoped service '(Clock|Metrics)'" \
|| echo "$out" | grep -q "'PublicCtorOnly'"; then
echo "FAIL: a singleton->singleton, public-ctor-only, or clean registration was wrongly flagged captive"; exit 1
fi
# exactly four captive dependencies (direct + transitive + interface + primary-ctor).
nd=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI001\]")
[ "$nd" = "4" ] \
|| { echo "FAIL: expected exactly 4 DI001 captive findings, got $nd"; exit 1; }
# P-006 Q#1: each captive finding anchors at the registration site but ALSO names its
# CONSUMING CONSTRUCTOR (where the capture is injected) — the explicit ctor line for
# EmailSender, and the class-declaration line for the C# 12 primary ctor.
echo "$out" | grep -qE "consumed by the 'EmailSender' constructor at .*DiCaptiveSample\.cs:25\]" \
|| { echo "FAIL: expected the consuming-constructor anchor (EmailSender ctor, line 25)"; exit 1; }
echo "$out" | grep -qE "consumed by the 'PrimaryCtorService' constructor at .*DiCaptiveSample\.cs:33\]" \
|| { echo "FAIL: expected the primary-constructor consuming anchor (PrimaryCtorService, line 33)"; exit 1; }
# P-006 DI003 (transient IDisposable captured by a singleton, WARNING): the
# singleton ConnectionWarmer holds the transient IDisposable PooledConnection
# for the app lifetime (disposed only at root disposal). A warning, distinct
# from a DI001 — the "exactly 4 DI001" count above proves it is not miscounted.
echo "$out" | grep -qE "\[DI003\].*'ConnectionWarmer' captures transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI003 (ConnectionWarmer captures transient IDisposable PooledConnection)"; exit 1; }
nw=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI003\]")
[ "$nw" = "1" ] \
|| { echo "FAIL: expected exactly 1 DI003 finding, got $nw"; exit 1; }
# DI003 carries the consuming-constructor anchor too (ConnectionWarmer's ctor, line 50).
echo "$out" | grep -qE "consumed by the 'ConnectionWarmer' constructor at .*DiCaptiveSample\.cs:50\]" \
|| { echo "FAIL: expected the DI003 consuming-constructor anchor (ConnectionWarmer, line 50)"; exit 1; }
# P-006 DI002 (scoped service held by a singleton via WeakReference<T>, WARNING):
# the weak ref is the usual "fix" for a DI001 captive, but scoped AppDbContext is
# still root-resolved and app-lived — the lifetime contract is still violated. The
# weak edge is OFF the strong graph, so WeakCache is a DI002, NOT a 5th DI001 (the
# "exactly 4 DI001" count above proves it). A weak ref to a SINGLETON
# (WeakClockHolder -> Clock) is no mismatch -> silent.
echo "$out" | grep -qE "\[DI002\].*'WeakCache' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI002 (WeakCache weakly captures scoped AppDbContext)"; exit 1; }
# a NULLABLE WeakReference<AppDbContext>? is the same weak captive — the `?` annotation
# is unwrapped, so the scoped service is still seen (CodeRabbit review on #63).
echo "$out" | grep -qE "\[DI002\].*'WeakCacheOpt' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI002 on the nullable WeakReference (WeakCacheOpt)"; exit 1; }
# transitive DI002: a singleton weakly holds the transient UnitOfWork, which strongly
# drags in scoped AppDbContext (WeakReport -> UnitOfWork -> AppDbContext). The weak DFS
# follows the transient's strong edges like DI001 does.
echo "$out" | grep -qE "\[DI002\].*'WeakReport' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected transitive DI002 (WeakReport -> UnitOfWork -> AppDbContext)"; exit 1; }
# pin the rendered transitive PATH (not just the finding), so a path-rendering
# regression fails CI (CodeRabbit review on #64).
echo "$out" | grep -q "WeakReport -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive DI002 path text"; exit 1; }
nwk=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI002\]")
[ "$nwk" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI002 findings, got $nwk"; exit 1; }
# DI002 carries the consuming-constructor anchor too (WeakCache's ctor, line 57).
echo "$out" | grep -qE "consumed by the 'WeakCache' constructor at .*DiCaptiveSample\.cs:57\]" \
|| { echo "FAIL: expected the DI002 consuming-constructor anchor (WeakCache, line 57)"; exit 1; }
if echo "$out" | grep -q "WeakClockHolder"; then
echo "FAIL: a weak ref to a singleton (WeakClockHolder) was wrongly flagged"; exit 1
fi
# P-006 DI004 (transient IDisposable resolved BY HAND from the root IServiceProvider,
# WARNING): a singleton that service-locates a transient IDisposable off its injected
# root provider — tracked to app shutdown. This is a CALL SITE the registration graph
# (DI001/2/3) cannot see, so it is the unique slice. Three flagged shapes:
# - ConnectionResolver -> PooledConnection (block-bodied ctor, direct)
# - ExprBodiedResolver -> PooledConnection (EXPRESSION-bodied ctor; Codex)
# - WrapperResolver -> MidConnection -> PooledConnection (TRANSITIVE: the root builds
# the non-disposable wrapper's transient subtree; the DFS mirrors DI003; Codex)
echo "$out" | grep -qE "\[DI004\].*'ConnectionResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI004 (ConnectionResolver service-locates PooledConnection)"; exit 1; }
echo "$out" | grep -qE "\[DI004\].*'ExprBodiedResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI004 on the expression-bodied ctor (ExprBodiedResolver)"; exit 1; }
echo "$out" | grep -qE "\[DI004\].*'WrapperResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected transitive DI004 (WrapperResolver -> MidConnection -> PooledConnection)"; exit 1; }
# pin the rendered transitive PATH (not just the finding), like the DI002 transitive case.
echo "$out" | grep -q "WrapperResolver -> MidConnection -> PooledConnection" \
|| { echo "FAIL: expected the transitive DI004 path text"; exit 1; }
n4=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI004\]")
[ "$n4" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI004 findings, got $n4"; exit 1; }
# DI004's consumer is the GetRequiredService CALL SITE (not a ctor) — and the leak IS
# that call, so it is the PRIMARY anchor (the line prefix), not the registration site
# (Codex review): ConnectionResolver:79, ExprBodiedResolver:123, transitive
# WrapperResolver:137 (the entry MidConnection's call, not the dragged-in disposable).
echo "$out" | grep -qE "DiCaptiveSample\.cs:79: warning: \[DI004\].*'ConnectionResolver'" \
|| { echo "FAIL: expected DI004 ConnectionResolver ANCHORED at its call site (line 79)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:123: warning: \[DI004\].*'ExprBodiedResolver'" \
|| { echo "FAIL: expected DI004 ExprBodiedResolver anchored at its call site (line 123)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:137: warning: \[DI004\].*'WrapperResolver'" \
|| { echo "FAIL: expected transitive DI004 WrapperResolver anchored at the entry call site (line 137)"; exit 1; }
# the registration site rides along as the SECONDARY anchor (named in EACH DI004
# message tail) — exactly 3, one per finding, so a partial-suffix regression (the tail
# on some findings but not all) fails CI too (CodeRabbit review; mirrors the counts above).
nreg=$(echo "$out" | grep -c "singleton registered at ")
[ "$nreg" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI004 registration-site suffixes, got $nreg"; exit 1; }
# the three controls each pin one precision guard and must stay SILENT: ScopedResolver
# resolves from a SCOPE it creates (scope.ServiceProvider — the correct shape);
# PlainResolver resolves a NON-disposable transient whose only dep is scoped (the root
# does not track it); RequestResolver is SCOPED (its injected provider is the request
# scope, not the root). MidConnection itself (a transient wrapper) is not a singleton.
if echo "$out" | grep -qE "(ScopedResolver|PlainResolver|RequestResolver)"; then
echo "FAIL: a correct/non-leaking resolver (scope-resolved, non-disposable, or scoped) was wrongly flagged DI004"; exit 1
fi
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) at the C# location"
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
run: |
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1
# detector cannot catch (use-after-dispose, double-dispose, leak-on-path).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FlowLocalsSample.cs \
frontend/roslyn/samples/MemoryOwnerEscapeSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/flow.json" || true)
echo "$out"
echo "$out" | grep -q "OWN002" || { echo "FAIL: expected OWN002 (use-after-dispose)"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 (leak on a path)"; exit 1; }
echo "$out" | grep -q "OWN003" || { echo "FAIL: expected OWN003 (double-dispose)"; exit 1; }
# a real Timer leak the flat curated allowlist misses but the semantic path catches:
echo "$out" | grep -q "OWN001.*'realTimer'" || { echo "FAIL: expected OWN001 on the leaked Timer"; exit 1; }
# the OWN001 wording splits on whether the local was released anywhere: the
# Timer is released on no path -> "is never disposed"; LeakOnElse's `leak` is
# released on the then-branch only -> "may not be disposed on every path".
echo "$out" | grep -qE "'realTimer' is never disposed" \
|| { 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`/`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; }
echo "$out" | grep -qE "OWN001.*'forLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a for loop"; exit 1; }
# `try`/`finally` lowered with exception edges (try-methods no longer skipped):
# a local never disposed inside a try is caught...
echo "$out" | grep -qE "OWN001.*'tfLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a try-method"; exit 1; }
# ...and so is dispose-not-called-on-throw: `dot` is disposed inside the try
# after a may-throw call, so it leaks on the exceptional path (matches CodeQL).
echo "$out" | grep -qE "OWN001.*'dot'" \
|| { echo "FAIL: expected OWN001 on the dispose-not-called-on-throw local"; exit 1; }
# exception-edge RECALL slice — three sound recall wins, each matching CodeQL's
# cs/dispose-not-called-on-throw: a may-throw in a nested `if` branch BEFORE the
# dispose ('nestedLeak'); a constructor (`new`) as a throw point that skips a PRIOR
# owned resource's dispose ('ctorPrior'); and a TYPED catch whose uncaught exception
# types propagate past a post-try dispose ('typedLeak').
echo "$out" | grep -qE "OWN001.*'nestedLeak'" \
|| { echo "FAIL: expected OWN001 on the nested-throw leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'ctorPrior'" \
|| { echo "FAIL: expected OWN001 on the constructor-throw prior-resource leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'typedLeak'" \
|| { echo "FAIL: expected OWN001 on the typed-catch uncaught-path leak"; exit 1; }
# ...and a qualified DOMAIN catch (`catch (DomainErrors.Exception)` — rightmost name
# `Exception` but NOT System.Exception) is typed too, so its uncaught types leak
# ('qualLeak'); IsCatchAll matches only the canonical spellings (CodeRabbit review).
echo "$out" | grep -qE "OWN001.*'qualLeak'" \
|| { echo "FAIL: expected OWN001 on the qualified-typed-catch leak"; exit 1; }
# remaining flow-lowering gaps closed: finally-before-return threading (an early
# return that skips a later dispose leaks -> 'earlyRet'), `do` desugar (a body-local
# never disposed leaks per iteration -> 'doLeak'), and `switch` lowering (a default
# branch that does not dispose leaks -> 'swLeak').
echo "$out" | grep -qE "OWN001.*'earlyRet'" \
|| { echo "FAIL: expected OWN001 on the early-return-skips-dispose leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'doLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a do-while loop"; exit 1; }
echo "$out" | grep -qE "OWN001.*'swLeak'" \
|| { echo "FAIL: expected OWN001 on the switch default-branch leak"; exit 1; }
# closure-capture escape (precision): a SemaphoreSlim captured by a returned async
# lambda outlives the method, so it cannot be disposed at method scope -> escaped ->
# silent ('captured'). A SemaphoreSlim NOT captured and never disposed STILL leaks ->
# OWN001 ('semLeak'), proving the exemption is closure-capture, not a blanket
# SemaphoreSlim dispose-optional (reduced from a ShareX FP — Helpers.ForEachAsync).
echo "$out" | grep -qE "OWN001.*'semLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the non-captured SemaphoreSlim leak"; exit 1; }
# a `nameof(x)` operand inside a lambda is NOT a closure capture (it is a compile-time
# string) -> the local stays method-bounded and still leaks -> OWN001 (Codex review on
# #59: nameof must not masquerade as a capture/escape).
echo "$out" | grep -qE "OWN001.*'nofLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the nameof-in-lambda local (not a capture)"; exit 1; }
# owning-factory RECALL (crypto): a System.Security.Cryptography static `Create*` factory
# returning an IDisposable is an owning acquire like File.Open*/Create* -> an undisposed
# one leaks ('rngLeak' = RandomNumberGenerator.Create()); the disposed sibling
# ('shaClean' = SHA256.Create() + Dispose) stays silent. Reduced from the SECOND,
# previously-missed leak in ShareX's DeriveCryptoData.
echo "$out" | grep -qE "OWN001.*'rngLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed crypto-factory acquire"; exit 1; }
# TcpListener precision (Codex review on #61): Stop() IS the cleanup (Dispose()
# delegates to Stop()), so a Stop()'d listener is modelled as released -> silent
# ('stopped'); a listener NEVER Stop()'d still holds the socket -> OWN001 ('tcpLeak'),
# proving the release is Stop()-specific, not a blanket TcpListener exemption.
echo "$out" | grep -qE "OWN001.*'tcpLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the never-stopped TcpListener"; exit 1; }
# dispose-optional (Task), disposed/escaping locals, a `for` loop whose
# disposable is disposed after it (`looped`, balanced), a balanced
# acquire+dispose in a loop (`whileClean`), a try/finally dispose (`tfClean`,
# balanced) and a catch-disposes method (`tfCatch`, soundly skipped) must
# stay silent:
# released via `await x.DisposeAsync()` (asyncDisposed) and the chained
# `.ConfigureAwait(false)` form (asyncDisposedCfg) -> both must stay silent.
# PR #32 FP fixes: a swallowing catch with a Dispose AFTER the try/catch (cda),
# an `await DisposeAsync().ConfigureAwait(false)` INSIDE a try (daci), and a Dispose
# inside both branches of an `if` in a try alongside a may-throw call (cif) — all
# disposed on every path, so all must stay silent (were false OWN001 before).
# `ctorLater` is acquired AFTER the constructor-throw edge in CtorThrowLeaksPrior, so
# it is never live at that edge and must stay silent (only `ctorPrior` leaks there).
# `lamPrior`: a `new` inside a LAMBDA body is deferred (runs on invoke, not at the
# declaration), so the lambda statement is not a throw point -> no phantom edge skips
# its post-try dispose -> silent (Codex review: don't descend into lambda bodies).
# `other`: disposed by the finally, so threaded before the early return -> silent.
# `doClean`: acquire+dispose balanced each `do` iteration. `swAll`: every `switch`
# case disposes (no default) -> last case is the tail, no phantom no-match leak.
# `ncf`: `ncf?.Dispose()` (null-conditional) in a threaded finally IS a release
# (member-binding form), so it is disposed on the return path -> silent (Codex review).
# `ctorMoved`: a pooled buffer handed to a constructor whose result is RETURNED transfers
# ownership to the returned wrapper -> escaped -> silent (mined FP on
# Pipelines.Sockets.Unofficial: ArrayPoolBufferWriter.CreateNewSegment).
# P-016 escape-via-projection (mined: ImageSharp Image.WrapMemory; CodeQL agrees it is
# no leak): an IMemoryOwner whose `.Memory` view is handed to a consumer as an argument
# escapes the owner -> silent ('handedOwner', in the list below); one whose `.Memory` is
# only READ locally and never disposed still leaks ('leakedOwner').
echo "$out" | grep -qE "OWN001.*'leakedOwner'" \
|| { echo "FAIL: expected OWN001 on the read-only, never-disposed IMemoryOwner"; exit 1; }
# boundary: the projection-escape is scoped to `new`'d owners — a MemoryPool RENTAL whose
# .Memory is handed off after Dispose must KEEP its use-after-dispose tracking, NOT be
# silenced (Codex/CodeRabbit P1; benchmark memorypool-double-dispose parity).
echo "$out" | grep -qE "OWN002.*'pooled'" \
|| { echo "FAIL: a MemoryPool owner's .Memory used after Dispose must still trip OWN002"; exit 1; }
for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg tfClean tfCatch tfRet tfNull cda daci cif ctorLater lamPrior other doClean swAll ncf captured shaClean stopped defer ctorMoved handedOwner; 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/for, try/finally sequential, 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
# counts into the facts JSON: of the methods with a disposable local, how
# many were flow-analysed vs honestly skipped (an unmodelled construct).
cov=$(dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FlowLocalsSample.cs --flow-locals --stats \
-o "$RUNNER_TEMP/stats.json" 2>&1 >/dev/null)
echo "$cov"
echo "$cov" | grep -qE '^coverage: [0-9]+/[0-9]+ methods .* flow-analysed' \
|| { echo "FAIL: expected a --stats coverage line on stderr"; exit 1; }
# Parse the JSON (not a substring grep): assert the stats object exists,
# all three counters are numbers, and the invariant holds — every method
# with a disposable local is either flow-analysed or honestly skipped.
jq -e '.stats as $s
| ($s.methods_with_local | type == "number")
and ($s.methods_flow_analysed | type == "number")
and ($s.methods_skipped_unmodelled | type == "number")
and ($s.methods_flow_analysed + $s.methods_skipped_unmodelled
== $s.methods_with_local)' \
"$RUNNER_TEMP/stats.json" >/dev/null \
|| { echo "FAIL: stats object missing / non-numeric / invariant violated";
cat "$RUNNER_TEMP/stats.json"; exit 1; }
echo "OK: --stats coverage on stderr + valid stats object (invariant holds)"
- name: Escape-via-projection leak — GTM UnitOfWork (--flow-locals, P-016 B0b/B2)
run: |
# A real GTM leak the flat detector misses: a UnitOfWork (IDisposable) used
# ONLY through member access to build a returned DEFERRED IQueryable. The
# bare `uow` never escapes, so it stays tracked and is disposed on no path
# -> OWN001. Crucially NOT fixable by a naive `using` (the deferred query
# would run after dispose) — the `using var`+materialize fix (uowFixed) is
# the one that must stay silent.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/UnitOfWorkFlowSample.cs --flow-locals -o "$RUNNER_TEMP/uow.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/uow.json" || true)
echo "$out"
# `uow` is released on no path, so the OWN001 reads "is never disposed".
echo "$out" | grep -qE "OWN001.*'uow' is never disposed" \
|| { echo "FAIL: expected OWN001 'is never disposed' on UnitOfWork 'uow'"; exit 1; }
echo "$out" | grep -q "UnitOfWorkFlowSample.cs" \
|| { echo "FAIL: expected the finding at the C# sample location"; exit 1; }
# the three correct fixes must stay silent: materialize inside `using`
# (uowFixed), and ownership TRANSFERRED to the caller — returned (uowOwned)
# or moved out as an argument (uowMoved).
for ok in uowFixed uowOwned uowMoved; do
if echo "$out" | grep -q "'$ok'"; then
echo "FAIL: a correct fix ('$ok') was wrongly reported as a leak"; exit 1
fi
done
echo "OK: escape-via-projection UnitOfWork leak -> OWN001 'never disposed'; materialize + ownership-transfer fixes stay silent"
- name: WinForms modeless-form precision (--flow-locals, P-016)
run: |
# WinForms owns a *modeless* form's lifetime: a form shown via Form.Show()
# is disposed by the framework on close, so the extractor models that Show()
# as a RELEASE at the show site (ownership transfers to the framework there).
# A *modal* dialog shown via ShowDialog() is the caller's to dispose ->
# ShowDialog is NOT a release, so it stays tracked and an undisposed one is a
# real OWN001. Reduced from a ShareX (WinForms) false positive: our WPF-tuned
# local-disposable detector over-fired on the idiomatic `new SomeForm().Show()`.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/WinFormsModelessSample.cs --flow-locals -o "$RUNNER_TEMP/winforms.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/winforms.json" || true)
echo "$out"
# the modal dialog never disposed is a real leak (ShowDialog is caller-owned):
echo "$out" | grep -qE "OWN001.*'modalLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed ShowDialog modal dialog"; exit 1; }
echo "$out" | grep -q "WinFormsModelessSample.cs" \
|| { echo "FAIL: expected the finding at the C# sample location"; exit 1; }
# because Show() is a release AT THE SHOW SITE (path-sensitive, not a method-wide
# exemption), a form shown only on one branch leaks on the branch that never shows
# it -> OWN001 'may not be disposed on every path' (Codex review on PR #57).
echo "$out" | grep -qE "OWN001.*'condForm' may not be disposed on every path" \
|| { echo "FAIL: expected OWN001 on the conditionally-shown modeless form"; exit 1; }
# the precision fix: an unconditionally-shown modeless form (`.Show()`, ownership
# transferred to the framework) must stay silent, and so must a properly-disposed
# modal dialog (modalOk):
for ok in modeless modalOk; do
if echo "$out" | grep -q "'$ok'"; then
echo "FAIL: silent case '$ok' was reported (WinForms precision)"; exit 1
fi
done
echo "OK: WinForms modeless Form.Show() = call-site release (framework-owned); conditional show leaks on the no-show path; modal ShowDialog() leak caught; disposed modal silent"
# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
# directory of real C# and prints findings in the host-parseable formats the
# GitHub Action (PR annotations) and a VS Error List (MSBuild) consume — and
# the composite action itself runs end-to-end. One checker: the script just
# chains the extractor and the Python core.
own-check-surface:
name: own-check repo scan (github + msbuild) + composite action
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: GitHub-annotation format over the sample tree (directory walk)
run: |
# stdout (captured) carries only the annotations; the extractor's build
# chatter and any error flow to stderr -> the job log (never muted).
out=$(scripts/own-check.sh --format github -- frontend/roslyn/samples)
echo "--- annotations ---"; echo "$out"; echo "-------------------"
echo "$out" | grep -q "^::error " \
|| { echo "FAIL: expected a ::error annotation"; exit 1; }
echo "$out" | grep -q "frontend/roslyn/samples/CustomerViewModel.cs" \
|| { echo "FAIL: expected the relative path to the Customer leak"; exit 1; }
echo "$out" | grep -q "title=OWN001" \
|| { echo "FAIL: expected the OWN001 title in the annotation"; exit 1; }
- name: MSBuild diagnostic format over the sample tree (severity tiering)
run: |
out=$(scripts/own-check.sh --format msbuild -- frontend/roslyn/samples)
echo "--- diagnostics ---"; echo "$out"; echo "-------------------"
# P-004 tiering at the default severity, both sides: an injected-source
# subscription (CustomerViewModel's `bus` is a ctor param of unknown
# lifetime) renders as a WARNING, while a provable leak — the started,
# never-stopped timer — stays an ERROR.
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): warning OWN001:" \
|| { echo "FAIL: expected CustomerViewModel as a warning (injected source)"; exit 1; }
echo "$out" | grep -qE "TimerViewModel\.cs\([0-9]+\): error OWN001:" \
|| { echo "FAIL: expected the timer leak to stay an error"; exit 1; }
- name: --severity warning renders advisory diagnostics
run: |
out=$(scripts/own-check.sh --format msbuild --severity warning -- frontend/roslyn/samples)
echo "$out"
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): warning OWN001:" \
|| { echo "FAIL: expected an MSBuild-format warning line"; exit 1; }
if echo "$out" | grep -qE ": error OWN001:"; then
echo "FAIL: --severity warning should not emit error-level lines"; exit 1
fi
- name: --fail-on-finding propagates the core's exit code
run: |
if scripts/own-check.sh --fail-on-finding -- frontend/roslyn/samples >/dev/null 2>&1; then
echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1
fi
echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit"
- name: SARIF surface is a valid 2.1.0 log (the structure code scanning enforces)
run: |
# The contract GitHub's code-scanning ingest enforces, checked locally so
# the upload (own-check-codescan job) is never the first place a drift is
# found: a single-run 2.1.0 log, the Own.NET driver, and every result
# carrying a catalogue ruleId + a located file. No upload, no permissions.
out="$RUNNER_TEMP/own.sarif"
scripts/own-check.sh --format sarif --severity warning -- frontend/roslyn/samples > "$out"
echo "wrote $(wc -c < "$out") bytes"
jq -e '.version == "2.1.0" and ((.runs | length) == 1)' "$out" >/dev/null \
|| { echo "FAIL: not a single-run SARIF 2.1.0 log"; exit 1; }
jq -e '.runs[0].tool.driver.name == "Own.NET"' "$out" >/dev/null \
|| { echo "FAIL: tool.driver.name is not Own.NET"; exit 1; }
# A dangling ruleId or an unlocated result is the #1 reason GitHub rejects
# a SARIF; startLine is optional (a file-level finding omits it -> // 1).
jq -e '
(.runs[0].tool.driver.rules | map(.id)) as $ids
| .runs[0].results
| (length > 0)
and all(.[];
(.ruleId | type == "string")
and ((([.ruleId] - $ids) | length) == 0)
and (.locations[0].physicalLocation.artifactLocation.uri | type == "string")
and ((.locations[0].physicalLocation.region.startLine // 1) | type == "number"))
' "$out" >/dev/null \
|| { echo "FAIL: a result is unlocated or references an undeclared rule"; exit 1; }
echo "OK: SARIF 2.1.0 — Own.NET driver, every result rule-backed + located"
- name: The composite action runs end-to-end (non-failing)
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "false"
# Dog-food the code-scanning surface end-to-end (P-013): run the composite action
# with format: sarif over the sample tree, then upload the log to GitHub code
# scanning. The repo is public, so code scanning is free — this is the live proof
# that GitHub *accepts* our SARIF (upload-sarif waits for processing and fails the
# job if the log is rejected), not just that it is schema-valid (the surface job
# above). It also lights up the Security tab + inline PR annotations — the
# consumer-facing payoff the exporter was built for. The samples are intentional
# leak fixtures, so the alerts are real-if-intentional; a dedicated
# `own-net-samples` category keeps them from colliding with anything else.
own-check-codescan:
name: own-check SARIF -> GitHub code scanning (dog-food)
runs-on: ubuntu-latest
# Skip on fork PRs: GitHub downgrades GITHUB_TOKEN to read-only for a
# pull_request from a fork, so security-events:write is never granted and the
# upload would fail — red CI for an external contributor through no fault of
# their own. Same-repo pushes and PRs (where the token keeps write) still run.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# The one job that writes: scoped to security-events so it can upload to code
# scanning. Every other job stays contents:read (the workflow-level default).
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Own.NET leak check (SARIF surface)
id: own
uses: ./
with:
path: frontend/roslyn/samples
format: sarif
severity: warning # include the injected-source (warning-tier) leaks
fail-on-finding: "false" # let code scanning be the gate, not the step
- name: The action exposes the SARIF path
run: |
f="${{ steps.own.outputs.sarif-file }}"
test -n "$f" || { echo "FAIL: action did not set the sarif-file output"; exit 1; }
test -s "$f" || { echo "FAIL: sarif-file '$f' is missing or empty"; exit 1; }
echo "OK: action wrote $(wc -c < "$f") bytes to $f"
- name: Upload to GitHub code scanning
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.own.outputs.sarif-file }}
category: own-net-samples
# P-012 slice 1: score the checker against the labeled corpus on REAL C# — not
# just the .own reduction tests/test_corpus.py checks. Per case: the bug must be
# CAUGHT in before.cs (recall) and the fix must be SILENT in after.cs
# (specificity / no false alarm). A defensible, regression-pinned number — and
# the RLVR reward scaffold: a deterministic verifier over labeled real-C# data.
corpus-benchmark:
name: corpus benchmark (real C# recall + specificity)
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"
# Some corpus cases subscribe to framework events (WPF Window, Microsoft.Win32
# SystemEvents); the type-aware extractor needs those refs to bind a `+=` to an
# event (else an OWN050 note, not a leak). Materialize the WindowsDesktop ref
# pack and export OWN_EXTRA_REF_DIRS — same mechanism as the oracle/mine jobs.
# Harmless for the self-contained cases (deduped against the runtime TPA).
- name: Materialize framework reference assemblies
continue-on-error: true
run: |
tmp=$(mktemp -d)
printf '%s\n' \
'<Project Sdk="Microsoft.NET.Sdk">' \
' <PropertyGroup>' \
' <TargetFramework>net8.0-windows</TargetFramework>' \
' <UseWPF>true</UseWPF>' \
' <UseWindowsForms>true</UseWindowsForms>' \
' <EnableWindowsTargeting>true</EnableWindowsTargeting>' \
' </PropertyGroup>' \
'</Project>' > "$tmp/ref.csproj"
dotnet restore "$tmp/ref.csproj" >/dev/null 2>&1 || echo "ref restore failed (continuing)"
d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true)
if [ -n "$d" ]; then
echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV"
echo "framework refs: $d ($(find "$d" -name '*.dll' | wc -l) dlls)"
else
echo "framework refs not found — own-check resolves runtime types only"
fi
- name: Score the corpus on real C#
# Precision is gated absolutely (every fix silent, zero false positives);
# recall is pinned at the measured floor (the --min-recall value below, bumped
# per ratchet) and climbs as the extractor improves — pooled buffers ride the
# path-sensitive flow engine
# (Rent/Return: OWN003/OWN002, pool resolved via the Roslyn SemanticModel so an
# ALIASED receiver is caught), factory acquires (System.IO.File.Open*/Create*) are
# recognised alongside `new`, and the inter-procedural CONSUME contract is modelled:
# a first-party method that owns a by-value IDisposable param — by disposing it
# directly OR by forwarding it to another first-party consumer (the TRANSITIVE chain,
# `ConsumesParam`) — is a handoff that releases the argument at the call site, so a use
# after the handoff trips OWN002 (the cut is the signature, like Rust's move). The BORROW
# checker covers both view kinds: a `Span`/`Memory` view of a pooled buffer (`buf.AsSpan()` /
# `buf.AsMemory()`) is a borrow lowered to a use of the OWNER (`ViewOwner`), so using it
# after `Return(buf)` trips OWN002 — including RETURNING a `Memory<T>` view (which, unlike a
# ref-struct `Span`, can ESCAPE the method), a dangling borrow handed to the caller. A
# view of a pooled buffer reaches past the rented length into the oversized tail -> OWN025
# (P-007 POOL005, the over-read): the unbounded `buf.AsSpan()` AND the `.Length` view spelling
# (`buf.AsSpan(0, buf.Length)`) — the `arraypool-fullspan-overread` / `arraypool-length-
# overread` cases (a write/wipe like `Array.Clear(buf, 0, buf.Length)` is not flagged).
# MemoryPool is tracked too: a `MemoryPool<T>.Rent` IMemoryOwner is released by Dispose, so its
# leak / double-dispose ride the flow (POOL001/003 — `memorypool-double-dispose` -> OWN003), and
# its `owner.Memory` / `owner.Memory.Span` view is a borrow lowered to a use of the OWNER
# (`ViewOwner`), so reading it after Dispose trips OWN002 (POOL002 — `memorypool-view-after-
# dispose`). Returning the BARE owner under `using` (`using owner = …; return owner;`) is the twin
# of the returned-view dangle: the using-owner stays tracked through the bare return (a non-using
# transfer does not) and its use is threaded after the scope-exit release -> OWN002 (`memorypool-
# using-owner-escape`). A FIELD-mediated cross-method use-after-dispose is caught too: an IDisposable
# field disposed in `Dispose()` and read in a live subscription-target handler (RHS of a `+=` / arg
# of a `.Subscribe(...)`, not torn down, no `if (_disposed) return;` guard) — DIRECTLY
# (`field-use-after-dispose`) or ONE hop down through a private helper (`handler-use-after-dispose`)
# — lowered to a synthetic acquire/release/use flow -> OWN002. That pass also covers POOLED owners:
# an `IMemoryOwner<T>` field released in `Dispose()` and a `Memory` VIEW field of it (`_view =
# _owner.Memory`) read in such a handler is the view-in-a-field dangle -> OWN002 (`pooled-view-after-
# dispose`). Remaining backlog: an ArrayPool `byte[]`-buffer-field view, a TWO-plus-hop indirect field
# use, and an injected-source region-escape. A drop below the floor is a regression.
run: python scripts/benchmark.py --min-recall 23