Skip to content

feat(extractor): pooled buffers through the flow engine → OWN003/OWN002 (recall 4/9→6/9) #309

feat(extractor): pooled buffers through the flow engine → OWN003/OWN002 (recall 4/9→6/9)

feat(extractor): pooled buffers through the flow engine → OWN003/OWN002 (recall 4/9→6/9) #309

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 \
-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
# 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
# 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; }
# 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-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; }
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) 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 --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; }
# 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).
for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg tfClean tfCatch tfRet tfNull cda daci cif ctorLater lamPrior other doClean swAll ncf; 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"
# 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 and ratchets up as the extractor
# improves. Now 6/9 — pooled buffers are routed through the path-sensitive
# flow engine (Rent = acquire, Return = release), so double-return (OWN003)
# and use-after-return (OWN002) join the already-caught subscription/region
# class. Remaining backlog: interprocedural handoff, cross-method
# use-after-dispose, a region-escape shape. A drop below the floor is a regression.
run: python scripts/benchmark.py --min-recall 6