Merge pull request #160 from PhysShell/claude/mos-ownership-summary-n… #985
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 "Where it cheats" 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 + contrib) | |
| 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 | |
| python scripts/validate_contrib.py --selftest | |
| # Own.NET Audit (audit/) — the aggregation layer's selftests, the only thing the | |
| # Linux CI gates for the audit (the target itself is analyzed on a local Windows | |
| # machine, never in CI; see audit/README.md and Plan.md §3.2). PyYAML is scoped | |
| # to audit/ here so the core test suite stays zero-dependency. | |
| audit-selftests: | |
| name: audit aggregation selftests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install audit deps (PyYAML, audit-scoped) | |
| run: pip install -r audit/requirements.txt | |
| - name: Own.NET Audit selftests (normalize + score + report + orchestrator) | |
| run: | | |
| python audit/aggregate/normalize.py --selftest | |
| python audit/aggregate/score.py --selftest | |
| python audit/aggregate/report.py --selftest | |
| python audit/static/tools/xaml_check.py --selftest | |
| python audit/static/tools/xaml_facts.py --selftest | |
| python audit/static/tools/xaml_join.py --selftest | |
| python audit/static/run_static.py --selftest | |
| python audit/runtime/ingest.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 \ | |
| frontend/roslyn/samples/SemaphoreFieldSample.cs \ | |
| frontend/roslyn/samples/VoidSubscribeSample.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 | |
| # the same rule for string-backed reader/writer fields (field-notes #8, Newtonsoft | |
| # TraceJsonReader/Writer): a new'd, undisposed StringWriter/StringReader holds no | |
| # unmanaged resource -> must stay SILENT (IsDisposeOptional, System.IO). | |
| if echo "$out" | grep -q "HolderWithStringWriter"; then | |
| echo "FAIL: a dispose-optional (StringWriter/StringReader) 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; } | |
| # P-004 SemaphoreSlim FIELD dispose-optional (mined: Npgsql NpgsqlDataSource._setupMappingsSemaphore): | |
| # a SemaphoreSlim field used only for Wait/Release (AvailableWaitHandle never read) frees nothing on | |
| # Dispose -> must be SILENT. | |
| if echo "$out" | grep -q "'_optionalSem'"; then | |
| echo "FAIL: a SemaphoreSlim field whose AvailableWaitHandle is never read was wrongly reported (dispose-optional)"; exit 1 | |
| fi | |
| # gate control: a SemaphoreSlim field whose AvailableWaitHandle IS read allocates a handle Dispose | |
| # must release -> it must STILL warn (proves the exemption is gated, not blanket — Codex). | |
| echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_handleSem'" \ | |
| || { echo "FAIL: a SemaphoreSlim field whose AvailableWaitHandle is read must still warn"; exit 1; } | |
| # Codex control: an AvailableWaitHandle read THROUGH A FIELD ALIAS must credit the field -> still warn. | |
| echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_aliasedSem'" \ | |
| || { echo "FAIL: an aliased AvailableWaitHandle read must keep the field tracked (alias-aware gate)"; exit 1; } | |
| # type-scope control: a non-SemaphoreSlim owned IDisposable (CTS) never disposed must STILL warn. | |
| echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_ctsControl'" \ | |
| || { echo "FAIL: a non-SemaphoreSlim owned IDisposable field must still warn (exemption stays SemaphoreSlim-scoped)"; exit 1; } | |
| # field-scoped: the existing method-bounded LOCAL SemaphoreSlim leak (FlowLocalsSample.semLeak) must | |
| # be UNAFFECTED — checked in the --flow-locals step below; this exemption never touches IsDisposeOptional. | |
| # 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; } | |
| # P-004 resolve-aware ignored-Subscribe (mined: StackExchange.Redis): a bare `x.Subscribe(...)` | |
| # whose call returns VOID (the Redis `ISubscriber.Subscribe(channel, handler, flags)` shape) has | |
| # no IDisposable token -> must be SILENT; the IDisposable-returning Subscribe still WARNs. | |
| if echo "$out" | grep -q "leaking 'VoidSubscriber'"; then | |
| echo "FAIL: a void-returning .Subscribe(...) was wrongly flagged as an ignored IDisposable subscription"; exit 1 | |
| fi | |
| echo "$out" | grep -q "leaking 'DisposableSubscriber'" \ | |
| || { echo "FAIL: an ignored IDisposable-returning .Subscribe(...) must still warn (resolve-aware stays scoped)"; exit 1; } | |
| # Codex control: a `dynamic` receiver's Subscribe has a dynamic return -> unprovable -> still WARN. | |
| echo "$out" | grep -q "leaking 'DynamicSubscriber'" \ | |
| || { echo "FAIL: an ignored dynamic .Subscribe(...) must still warn (dynamic return is unknown, not silenced)"; 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 -cE "\[DI004\].*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 | |
| # P-006 DI005 (scope-cached captive, WARNING): a singleton that resolves a SCOPED service | |
| # from a scope it CREATES (the correct IServiceScopeFactory pattern) but CACHES it into a | |
| # field — the scope is disposed when the operation ends, so the cached instance dangles | |
| # and is promoted to application lifetime (the captive returns, hidden behind the fix). | |
| echo "$out" | grep -qE "\[DI005\].*'ScopeCachingService' caches scoped service 'AppDbContext'" \ | |
| || { echo "FAIL: expected DI005 (ScopeCachingService caches scope-resolved scoped AppDbContext)"; exit 1; } | |
| # transitive DI005: a singleton caches the TRANSIENT UnitOfWork (which ctor-injects scoped | |
| # AppDbContext) from a created scope — the DFS follows the cached transient's strong edges | |
| # like DI001, so the dragged-in scoped service is found. A captive DI001/3/4 cannot see. | |
| echo "$out" | grep -qE "\[DI005\].*'UnitOfWorkCachingService' caches scoped service 'AppDbContext'" \ | |
| || { echo "FAIL: expected transitive DI005 (UnitOfWorkCachingService -> UnitOfWork -> AppDbContext)"; exit 1; } | |
| echo "$out" | grep -q "UnitOfWorkCachingService -> UnitOfWork -> AppDbContext" \ | |
| || { echo "FAIL: expected the transitive DI005 path text"; exit 1; } | |
| n5=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI005\]") | |
| [ "$n5" = "2" ] \ | |
| || { echo "FAIL: expected exactly 2 DI005 findings (direct + transitive), got $n5"; exit 1; } | |
| # DI005's consumer is the field-STORE site (not a ctor), the PRIMARY anchor — the direct | |
| # case at line 154 (`_db = ...AppDbContext`), the transitive case at the cached ENTRY's | |
| # store (line 201, `_uow = ...UnitOfWork`, NOT the dragged-in AppDbContext) — with the | |
| # registration as the secondary suffix. | |
| echo "$out" | grep -qE "DiCaptiveSample\.cs:154: warning: \[DI005\].*'ScopeCachingService'" \ | |
| || { echo "FAIL: expected DI005 anchored at the field-store site (line 154)"; exit 1; } | |
| echo "$out" | grep -qE "DiCaptiveSample\.cs:201: warning: \[DI005\].*'UnitOfWorkCachingService'" \ | |
| || { echo "FAIL: expected transitive DI005 anchored at the cached-entry store site (line 201)"; exit 1; } | |
| # the registration site rides along as the SECONDARY anchor in EACH DI005 message tail — | |
| # exactly 2 (one per finding), so a partial-suffix regression fails CI (like DI004's nreg). | |
| nreg5=$(echo "$out" | grep -cE "\[DI005\].*singleton registered at ") | |
| [ "$nreg5" = "2" ] \ | |
| || { echo "FAIL: expected exactly 2 DI005 registration-site suffixes, got $nreg5"; exit 1; } | |
| # two controls stay SILENT: ScopeUsingService USES the scope-resolved service within the | |
| # scope (a local, not a field store); ClockCachingService caches a SINGLETON (shareable, | |
| # not a scoped service). Neither is a captive. | |
| if echo "$out" | grep -qE "(ScopeUsingService|ClockCachingService)"; then | |
| echo "FAIL: a correct scope use (used-in-scope, or a cached singleton) was wrongly flagged DI005"; 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) + DI005 (scoped service cached from a created scope) 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 \ | |
| frontend/roslyn/samples/FactoryLeakSample.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; } | |
| # body-level explicit `throw` (no enclosing try) — these methods used to bail the flow | |
| # pass entirely (a `throw` hit the unmodelled default). Now an explicit throw is an | |
| # abnormal exit, so they are analysed: a top-level validation throw no longer HIDES a | |
| # later undisposed local ('vtl', never disposed), and a Dispose skipped by a `throw` | |
| # on the guard path leaks on that path ('dotNoTry', partial). Closes the no-try slice | |
| # of cs/dispose-not-called-on-throw + un-bails every validation-throw-guarded method. | |
| echo "$out" | grep -qE "OWN001.*'vtl' is never disposed" \ | |
| || { echo "FAIL: expected OWN001 on the local hidden behind a top-level validation throw"; exit 1; } | |
| echo "$out" | grep -qE "OWN001.*'dotNoTry' may not be disposed on every path" \ | |
| || { echo "FAIL: expected OWN001 on the body-level dispose-not-called-on-throw (no try)"; exit 1; } | |
| # flow-path pool LABEL: an ArrayPool Rent returned on one path only leaks on the other -> | |
| # the flow path must word it as a "pooled buffer" (Return), NOT the generic "disposable". | |
| # The extractor stamps the acquire kind; the bridge tags [resource: pooled buffer]. Pins | |
| # the mislabel fix surfaced by the --body-throw-edges Npgsql capstone. | |
| echo "$out" | grep -qE "OWN001.*pooled buffer 'partialBuf' may not be returned to the pool on every path" \ | |
| || { echo "FAIL: expected the flow-path pooled-buffer partial-path label on 'partialBuf'"; exit 1; } | |
| # b′ pooled-view REASSIGNMENT FP (ReassignedView): a Span view local reused for a SECOND | |
| # rented buffer no longer borrows the first. The pre-reassignment read (`v[0]` while bufA is | |
| # returned) is a REAL use-after-return -> EXACTLY ONE OWN002 on 'bufA'; the reassignment's own | |
| # LHS and the post-reassignment read (`v[1]`, now bufB) must NOT be re-attributed to the | |
| # released bufA (were two extra false OWN002 before the fix). bufB is returned after its last | |
| # read -> silent. The exact count is the guard: a regression re-introduces 2 more on bufA. | |
| nrv=$(echo "$out" | grep -cE "OWN002.*'bufA'") | |
| [ "$nrv" = "1" ] \ | |
| || { echo "FAIL: expected exactly 1 OWN002 on 'bufA' (pre-reassignment use-after-return), got $nrv"; exit 1; } | |
| if echo "$out" | grep -qE "OWN002.*'bufB'"; then | |
| echo "FAIL: a Span view reassigned to the live 'bufB' was wrongly flagged use-after-return"; exit 1 | |
| fi | |
| # Codex review on #98 — reslice-after-return: `sliced = sliced.Slice(1)` reads the STALE view | |
| # on the RHS, so it still trips OWN002 on 'sb' (the assignment's own LHS must not suppress its | |
| # own RHS). One finding only — the resliced view's forward owner is not tracked. | |
| echo "$out" | grep -qE "OWN002.*'sb'" \ | |
| || { echo "FAIL: expected OWN002 on 'sb' (reslice reads the returned buffer on the RHS)"; exit 1; } | |
| # Codex review on #98 — `ref` arg is a USE: passing a stale view by `ref` after return reads | |
| # the current value, so it is a use-after-return -> OWN002 on 'rb' (only `out` is exempt). | |
| echo "$out" | grep -qE "OWN002.*'rb'" \ | |
| || { echo "FAIL: expected OWN002 on 'rb' (ref arg reads the stale view after return)"; exit 1; } | |
| # Codex review on #98 (follow-up) — same-call out arg: `Reinit(out ov, ov[0])` reads the stale | |
| # view in a SIBLING argument (args evaluate before the callee writes the out param), so it | |
| # trips OWN002 on 'ob'; the out rebind must not suppress a use in the same invocation. | |
| echo "$out" | grep -qE "OWN002.*'ob'" \ | |
| || { echo "FAIL: expected OWN002 on 'ob' (sibling-arg read before the out-write)"; exit 1; } | |
| # Codex review on #98 (follow-up) — for-incrementor: the incrementor runs AFTER the body, so | |
| # `fv[0]` reads the stale view on iteration 1 even though `fv = default` sits earlier in the | |
| # header; the incrementor rebind must not suppress the body use -> OWN002 on 'fb'. | |
| echo "$out" | grep -qE "OWN002.*'fb'" \ | |
| || { echo "FAIL: expected OWN002 on 'fb' (for-incrementor must not suppress the body use)"; exit 1; } | |
| # CodeRabbit review on #98 — out-arg rebind: after `Reset(out ov)` the view no longer borrows | |
| # the returned 'obuf' (callee wrote an unknown value), so the later read is conservatively | |
| # SILENT (an honest miss, never a false positive). | |
| if echo "$out" | grep -qE "OWN002.*'obuf'"; then | |
| echo "FAIL: an out-rebound view must not be attributed to the returned 'obuf'"; exit 1 | |
| fi | |
| # 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; } | |
| # tdClean: a Dispose BEFORE an explicit `throw` -> released at the abnormal exit -> | |
| # silent. vtc: a local acquired after a top-level validation throw AND disposed -> | |
| # analysed (no longer bailed) and balanced -> silent (the un-bail must not over-flag). | |
| # tif (Codex P2): a `throw` inside an inner finally propagates through the OUTER finally | |
| # that disposes it -> the throw-exit keeps BAILING the method (it can't run the enclosing | |
| # cleanup) rather than emit a false leak -> silent. | |
| 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 tdClean vtc tif; do | |
| if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi | |
| done | |
| # P-005 D5.2 INTERPROCEDURAL fresh-returning factory (FactoryLeakSample.cs): the core | |
| # infers `StreamFactory.Make` returns `fresh` (its `acquire; return <var>` body), so a | |
| # caller that binds the result and drops it leaks at the CALL SITE — a finding the flat, | |
| # intra-procedural detectors cannot see. The disposed caller and the factory itself stay | |
| # silent (the factory transfers ownership out via its return). | |
| echo "$out" | grep -qE "FactoryLeakSample\.cs:[0-9]+:.*\[OWN001\].*'factoryLeak'" \ | |
| || { echo "FAIL: expected the interprocedural OWN001 on the dropped factory result at its call site"; exit 1; } | |
| for ok in factoryOk made; do | |
| if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.2 silent 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: P-005 D5.4 T4 wrap/adopt (--flow-locals) | |
| run: | | |
| # The extractor recognises a first-party wrapper that ADOPTS a disposable arg into an | |
| # owning field (its Dispose disposes that field; its ctor stores the arg into it) and | |
| # emits an `alias_join`, so the wrapper and the inner share ONE obligation: disposing | |
| # either is clean, dropping both leaks the resource ONCE, disposing both is OWN003. A | |
| # non-adopting holder makes NO alias claim (precision-first: no false double-dispose). | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/samples/FactoryAdoptSample.cs --flow-locals -o "$RUNNER_TEMP/adopt.json" | |
| out=$(python -m ownlang ownir "$RUNNER_TEMP/adopt.json" || true) | |
| echo "$out" | |
| # dropping both aliases leaks the ONE underlying resource once, on the inner local. | |
| echo "$out" | grep -qE "FactoryAdoptSample\.cs:[0-9]+:.*\[OWN001\].*'adoptInnerLeak'" \ | |
| || { echo "FAIL: expected the per-RID OWN001 on the dropped adopted inner (adoptInnerLeak)"; exit 1; } | |
| # disposing both aliases is a double-dispose through the shared obligation. | |
| echo "$out" | grep -q "OWN003" \ | |
| || { echo "FAIL: expected OWN003 (double-dispose through the alias)"; exit 1; } | |
| # clean / inner-only / non-adopt cases stay silent (no finding on their locals); the | |
| # leaked WRAPPER alias must also be silent (one finding, attributed to the inner). | |
| for ok in adoptInnerClean adoptWrapClean adoptInnerDirect adoptWrapDirect \ | |
| adoptInnerTt adoptWrapTt holdInner holdWrap adoptWrapLeak; do | |
| if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.4 case '$ok' must be silent"; exit 1; fi | |
| done | |
| echo "OK: D5.4 T4 alias_join on real C# — adopt verified (Dispose-field + ctor-param), double-dispose caught, non-adopt makes no claim" | |
| - name: Opt-in body-throw-edges tier (--body-throw-edges, P-016 throw firehose) | |
| run: | | |
| # The opt-in tier: body-level "any call may throw" dispose-not-called-on-throw (CodeQL | |
| # cs/dispose-not-called-on-throw parity on the no-try slice). OFF by default — its own | |
| # sample file is run in BOTH modes to prove the flag GATES the firehose (running it over | |
| # FlowLocalsSample would flood every acquire/use/dispose sample under the flag). | |
| # default (flag off): the may-throw case is SILENT (a body-level call is not a leak point). | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals -o "$RUNNER_TEMP/bte_off.json" | |
| off=$(python -m ownlang ownir "$RUNNER_TEMP/bte_off.json" || true) | |
| echo "$off" | |
| for s in mtbd mtf adc; do | |
| if echo "$off" | grep -q "'$s'"; then echo "FAIL: '$s' must be SILENT without --body-throw-edges"; exit 1; fi | |
| done | |
| # opt-in (flag on): the may-throw WriteByte between acquire and Dispose is a throw point | |
| # that skips the Dispose -> 'mtbd' leaks; 'adc' (adjacent dispose, nothing throws between) | |
| # stays silent even under the flag (the edge needs an intervening may-throw statement). | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals --body-throw-edges -o "$RUNNER_TEMP/bte_on.json" | |
| on=$(python -m ownlang ownir "$RUNNER_TEMP/bte_on.json" || true) | |
| echo "$on" | |
| echo "$on" | grep -qE "OWN001.*'mtbd' may not be disposed on every path" \ | |
| || { echo "FAIL: expected OWN001 on 'mtbd' under --body-throw-edges"; exit 1; } | |
| # adc: nothing throws between acquire and dispose. mtf (Codex P2): a may-throw call inside | |
| # a finally must not get a synthetic bare exit — the outer finally disposes it. Both silent | |
| # even under the flag. | |
| for s in adc mtf; do | |
| if echo "$on" | grep -q "'$s'"; then echo "FAIL: '$s' must stay silent even under --body-throw-edges"; exit 1; fi | |
| done | |
| echo "OK: --body-throw-edges gates the body-level dispose-not-called-on-throw firehose (off by default; fires only with an intervening may-throw; finally-internal throws excluded)" | |
| - 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" | |
| # Project-file input (the CLI-first project/solution resolution borrowed from the | |
| # roslyn-tools tooling shape): point the extractor at a .csproj instead of a file | |
| # list and assert the same event leak surfaces. Proves ProjectCsFiles resolves the | |
| # SDK-style project to its source set and feeds the core identically to the per-file | |
| # path. Both the positional and the `--project` flag forms are exercised. | |
| - name: Project-file input (.csproj -> source set -> core) | |
| run: | | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ | |
| -o "$RUNNER_TEMP/proj-facts.json" | |
| out=$(python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" || true) | |
| echo "$out" | |
| echo "$out" | grep -q "CustomerSubscription.cs" \ | |
| || { echo "FAIL: .csproj input did not resolve CustomerSubscription.cs"; exit 1; } | |
| echo "$out" | grep -qE "CustomerSubscription\.cs:[0-9]+:.*\[OWN001\]" \ | |
| || { echo "FAIL: expected OWN001 via .csproj input"; exit 1; } | |
| # the `--project` flag form must resolve to the same source set as the positional form. | |
| # Assert parity at the FACT boundary (canonicalized OwnIR), not after the Python core — | |
| # diffing rendered diagnostics could pass even if the two paths emit different facts the | |
| # core happens to collapse to the same warnings (CodeRabbit). | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ | |
| -o "$RUNNER_TEMP/proj-facts-flag.json" | |
| diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \ | |
| <(jq -S . "$RUNNER_TEMP/proj-facts-flag.json") \ | |
| || { echo "FAIL: --project flag and positional .csproj emit different OwnIR facts"; exit 1; } | |
| # the `extract` verb + `--out` long form must resolve to the same facts as the bare form. | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| extract --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ | |
| --out "$RUNNER_TEMP/proj-facts-verb.json" | |
| diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \ | |
| <(jq -S . "$RUNNER_TEMP/proj-facts-verb.json") \ | |
| || { echo "FAIL: 'extract --out' verb form disagrees with the bare form"; exit 1; } | |
| echo "OK: .csproj input resolves to its source set and feeds the core identically (bare == --project == 'extract --out', fact-level parity)" | |
| # --help renders the discoverable usage (commands/inputs/options) and exits 0. | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- --help > "$RUNNER_TEMP/help.txt" | |
| grep -q "Usage:" "$RUNNER_TEMP/help.txt" && grep -q -- "--no-project-refs" "$RUNNER_TEMP/help.txt" \ | |
| || { echo "FAIL: --help did not render the usage/options"; exit 1; } | |
| # --no-project-refs is accepted and (with no bin/ on the sample) yields identical facts. | |
| # Guard the precondition: the parity below only holds while the sample is unbuilt, so a | |
| # future step that builds it fails here with a clear message, not a confusing facts diff. | |
| [ ! -d frontend/roslyn/project-input-sample/bin ] \ | |
| || { echo "FAIL: sample project must be unbuilt for the --no-project-refs parity check"; exit 1; } | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| --no-project-refs --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ | |
| --out "$RUNNER_TEMP/proj-facts-norefs.json" | |
| diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \ | |
| <(jq -S . "$RUNNER_TEMP/proj-facts-norefs.json") \ | |
| || { echo "FAIL: --no-project-refs changed the facts for an unbuilt sample project"; exit 1; } | |
| echo "OK: --help renders the option list; --no-project-refs is accepted" | |
| # explain: the diagnostic-catalogue CLI surface lives in the core (one checker). Smoke | |
| # it end-to-end — explain a code, and harvest+explain every code in a real findings file. | |
| - name: explain command (code + --json harvest) | |
| run: | | |
| python -m ownlang explain OWN001 | grep -q "Fix:" \ | |
| || { echo "FAIL: explain OWN001 missing a Fix line"; exit 1; } | |
| # ownir exits 1 when findings exist (expected — the sample leaks); only 2+ is a real | |
| # error (bad facts / emit regression). Tolerate 1, surface 2+, so a genuine SARIF | |
| # generation failure is not masked by a blanket `|| true` (CodeRabbit). | |
| rc=0; python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" --format sarif > "$RUNNER_TEMP/proj.sarif" || rc=$? | |
| [ "$rc" -le 1 ] || { echo "FAIL: SARIF generation errored (rc=$rc)"; exit 1; } | |
| python -m ownlang explain --json "$RUNNER_TEMP/proj.sarif" | grep -q "OWN001" \ | |
| || { echo "FAIL: explain --json did not harvest OWN001 from the SARIF log"; exit 1; } | |
| echo "OK: explain answers a code and harvests codes from a real findings/SARIF file" | |
| # The OwnTS frontend spike (P-020 Own.React): the SAME OwnIR seam, fed from a | |
| # React .tsx instead of C#. Two analyses over the one core: (1) a useEffect | |
| # acquire (timer / subscribe / listener) with no cleanup return is the core's | |
| # OWN001 — the cross-language leak model; (2) EFF001, a NEW core analysis | |
| # (ownlang/effects.py) — an IO effect whose dependency identity is unstable | |
| # (a render-scope object literal) re-fires every render: the effect storm. The | |
| # frontend emits only facts; the stability verdict is the core's. No dotnet. | |
| ownts-react-effects: | |
| name: OwnTS (React useEffect) -> OwnIR -> core | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.13" | |
| - name: Pin the spike (leaky=3xOWN001+EFF001, clean=0, showcase=2xEFF001) | |
| run: python frontend/ownts/test_ownts.py | |
| - name: Extract OwnIR facts from a React .tsx and check through the core | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx \ | |
| -o "$RUNNER_TEMP/dash.facts.json" | |
| cat "$RUNNER_TEMP/dash.facts.json" | |
| out=$(python -m ownlang ownir "$RUNNER_TEMP/dash.facts.json" || true) | |
| echo "$out" | |
| echo "$out" | grep -q "Dashboard.tsx" \ | |
| || { echo "FAIL: expected findings located at the .tsx"; exit 1; } | |
| echo "$out" | grep -q "resource: timer" \ | |
| || { echo "FAIL: expected the setInterval [resource: timer] leak"; exit 1; } | |
| [ "$(echo "$out" | grep -c 'OWN001')" -eq 3 ] \ | |
| || { echo "FAIL: expected three OWN001 leaks"; exit 1; } | |
| echo "$out" | grep -q "\[EFF001\].*request storm" \ | |
| || { echo "FAIL: expected the EFF001 effect-storm verdict"; exit 1; } | |
| # exact finding count (a code-tagged line each), not a substring of "4 finding" | |
| [ "$(echo "$out" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 4 ] \ | |
| || { echo "FAIL: expected 3 OWN001 + 1 EFF001 = 4 findings"; exit 1; } | |
| - name: EFF001 stability showcase — only provable storms fire (low FP) | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectStorm.tsx \ | |
| -o "$RUNNER_TEMP/storm.facts.json" | |
| storm=$(python -m ownlang ownir "$RUNNER_TEMP/storm.facts.json" || true) | |
| echo "$storm" | |
| # the direct object dep and its derived alias fire; memo/ref/call/primitive/no-IO stay silent | |
| [ "$(echo "$storm" | grep -c '\[EFF001\]')" -eq 2 ] \ | |
| || { echo "FAIL: expected exactly two EFF001 (object dep + derived alias)"; exit 1; } | |
| echo "$storm" | grep -q "derives from" \ | |
| || { echo "FAIL: expected the derivation (propagation) verdict"; exit 1; } | |
| - name: Edge cases — partial timer cleanup + nested-scope shadow | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectEdges.tsx \ | |
| -o "$RUNNER_TEMP/edges.facts.json" | |
| edges=$(python -m ownlang ownir "$RUNNER_TEMP/edges.facts.json" || true) | |
| echo "$edges" | |
| # only the SECOND, uncleared interval leaks; the memoized dep is not shadowed | |
| [ "$(echo "$edges" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 1 ] \ | |
| || { echo "FAIL: expected exactly one OWN001 (the uncleared timer)"; exit 1; } | |
| echo "$edges" | grep -q "pollB" \ | |
| || { echo "FAIL: the leak must be the second (uncleared) interval"; exit 1; } | |
| - name: Parser hardening — string literals, nested dep brackets, listener options | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectHardening.tsx \ | |
| -o "$RUNNER_TEMP/hard.facts.json" | |
| hard=$(python -m ownlang ownir "$RUNNER_TEMP/hard.facts.json" || true) | |
| echo "$hard" | |
| # a string with commas/braces does not truncate the body (the timer is cleared); | |
| # the leak is the options-dropped listener; the object dep fires EFF001 once | |
| [ "$(echo "$hard" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 2 ] \ | |
| || { echo "FAIL: expected one OWN001 (listener) + one EFF001 (object dep)"; exit 1; } | |
| echo "$hard" | grep -q "scroll" \ | |
| || { echo "FAIL: the leak must be the options-dropped scroll listener"; exit 1; } | |
| - name: Expression-bodied cleanup with an options object is silent | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectExprCleanup.tsx \ | |
| -o "$RUNNER_TEMP/expr.facts.json" | |
| # no `|| true`: this case expects ZERO findings (rc 0), so a parser/core | |
| # crash (rc 2) must FAIL the step, not be swallowed into an empty result. | |
| expr=$(python -m ownlang ownir "$RUNNER_TEMP/expr.facts.json") | |
| echo "$expr" | |
| # the `{` of `{capture: true}` belongs to the call, not the cleanup block — | |
| # the listener is released, so no false-positive leak | |
| [ "$(echo "$expr" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \ | |
| || { echo "FAIL: a properly-released listener must not be reported"; exit 1; } | |
| - name: Real-world cleanup patterns (OSS-benchmark FP fixes) are silent | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectRealWorld.tsx \ | |
| -o "$RUNNER_TEMP/rw.facts.json" | |
| rw=$(python -m ownlang ownir "$RUNNER_TEMP/rw.facts.json") | |
| echo "$rw" | |
| # AbortController signal, ref/pre-declared timer handles, nested-block | |
| # cleanup, observer.subscribe/unsubscribe — all released, zero findings. | |
| [ "$(echo "$rw" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \ | |
| || { echo "FAIL: real-world cleanup patterns must not be reported"; exit 1; } | |
| - name: False-negative controls (wrong controller / args / conditional / async arrow+ES5) still leak | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectLeakControl.tsx \ | |
| -o "$RUNNER_TEMP/leak.facts.json" | |
| leak=$(python -m ownlang ownir "$RUNNER_TEMP/leak.facts.json" || true) | |
| echo "$leak" | |
| # a release-shaped cleanup that does not release THIS resource (wrong | |
| # controller / mismatched args / conditional return / async arrow effect / | |
| # async ES5 `function` effect) must not be over-suppressed — all five | |
| # controls stay OWN001 | |
| [ "$(echo "$leak" | grep -c 'OWN001')" -eq 5 ] \ | |
| || { echo "FAIL: broadened matchers must not over-suppress real leaks"; exit 1; } | |
| - name: ES5 `function` callbacks parse; capture-mismatch leak caught (real bug shape) | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/EffectFunctionCallback.tsx \ | |
| -o "$RUNNER_TEMP/fn.facts.json" | |
| fn=$(python -m ownlang ownir "$RUNNER_TEMP/fn.facts.json" || true) | |
| echo "$fn" | |
| # the matched ES5 cleanup is silent; the capture:true-vs-default mismatch | |
| # (react-scroll-to-bottom@4.2.0 shape) is the one OWN001 | |
| [ "$(echo "$fn" | grep -c 'OWN001')" -eq 1 ] \ | |
| || { echo "FAIL: expected exactly the capture-mismatch leak"; exit 1; } | |
| echo "$fn" | grep -q "focus" \ | |
| || { echo "FAIL: the leak must be the capture-mismatched focus listener"; exit 1; } | |
| - name: The clean fixture (cleanups + useMemo'd dep) is silent | |
| run: | | |
| python frontend/ownts/ownts.py frontend/ownts/examples/DashboardClean.tsx \ | |
| -o "$RUNNER_TEMP/clean.facts.json" | |
| clean=$(python -m ownlang ownir "$RUNNER_TEMP/clean.facts.json" || true) | |
| echo "$clean" | |
| [ "$(echo "$clean" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \ | |
| || { echo "FAIL: cleaned-up + memoised effects must not fire"; exit 1; } | |
| # 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-014 Tier B: external-reference resolution. The SAME sample, run two ways, must give two | |
| # verdicts — proving the extractor binds a THIRD-PARTY event only when its DLL is referenced: | |
| # A (no refs) -> ObservableObject is an error type -> OWN050 (honest skip), no leak | |
| # B (--ref-dir DLL) -> PropertyChanged binds to an IEventSymbol -> real OWN001 leak, no OWN050 | |
| # The package DLL is fetched from nuget (a .nupkg is a zip) and pinned to a version known to | |
| # expose the event; Roslyn reads its metadata only (no build, no source, no .NET Framework needed). | |
| tier-b-refs: | |
| name: P-014 Tier B — external reference resolution (--ref-dir) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.13" | |
| - uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: "8.0.x" | |
| - name: Materialize a third-party reference (CommunityToolkit.Mvvm 8.2.2, pinned) | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/refdir" | |
| curl -sSL --retry 3 --max-time 120 -o "$RUNNER_TEMP/ct.nupkg" \ | |
| "https://api.nuget.org/v3-flatcontainer/communitytoolkit.mvvm/8.2.2/communitytoolkit.mvvm.8.2.2.nupkg" | |
| # a .nupkg is a zip; lift just the netstandard2.0 assembly into the ref dir | |
| unzip -o -j "$RUNNER_TEMP/ct.nupkg" "lib/netstandard2.0/CommunityToolkit.Mvvm.dll" -d "$RUNNER_TEMP/refdir" | |
| test -s "$RUNNER_TEMP/refdir/CommunityToolkit.Mvvm.dll" \ | |
| || { echo "FAIL: could not materialize CommunityToolkit.Mvvm.dll"; exit 1; } | |
| # pre-flight: confirm the fixture DLL actually exposes the event the A/B test binds to — | |
| # read its .NET metadata in pure Python (no runtime). A clear "fixture rotted" failure | |
| # beats a confusing "OWN001 not found" if the package ever drops/renames the member. | |
| pip install --quiet dnfile | |
| python - <<'PY' | |
| import os, dnfile | |
| pe = dnfile.dnPE(os.path.join(os.environ["RUNNER_TEMP"], "refdir", "CommunityToolkit.Mvvm.dll")) | |
| ev = getattr(pe.net.mdtables, "Event", None) | |
| events = {str(r.Name) for r in ev.rows} if ev else set() | |
| types = {f"{r.TypeNamespace}.{r.TypeName}" for r in pe.net.mdtables.TypeDef.rows} | |
| assert "PropertyChanged" in events, f"fixture DLL missing PropertyChanged event; has {sorted(events)}" | |
| assert "CommunityToolkit.Mvvm.ComponentModel.ObservableObject" in types, "fixture DLL missing ObservableObject" | |
| print("pre-flight OK: ObservableObject + PropertyChanged present in fixture metadata") | |
| PY | |
| - name: A — without the reference, the external event is OWN050 (honest skip), not a leak | |
| run: | | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/samples/TierBSample.cs -o "$RUNNER_TEMP/a.json" | |
| out=$(python -m ownlang ownir "$RUNNER_TEMP/a.json" || true) | |
| echo "$out" | |
| echo "$out" | grep -q "\[OWN050\]" \ | |
| || { echo "FAIL(A): expected OWN050 — ObservableObject unresolved without --ref-dir"; exit 1; } | |
| if echo "$out" | grep -q "\[OWN001\]"; then | |
| echo "FAIL(A): must NOT guess a leak when the declaring type is unresolved"; exit 1 | |
| fi | |
| - name: B — with --ref-dir, the event resolves to a real subscription leak (OWN001) | |
| run: | | |
| dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ | |
| frontend/roslyn/samples/TierBSample.cs --ref-dir "$RUNNER_TEMP/refdir" -o "$RUNNER_TEMP/b.json" | |
| out=$(python -m ownlang ownir "$RUNNER_TEMP/b.json" || true) | |
| echo "$out" | |
| echo "$out" | grep -q "\[OWN001\]" \ | |
| || { echo "FAIL(B): expected OWN001 — PropertyChanged resolved via --ref-dir"; exit 1; } | |
| if echo "$out" | grep -q "\[OWN050\]"; then | |
| echo "FAIL(B): the external event must RESOLVE, not stay OWN050"; exit 1 | |
| fi | |
| echo "OK: Tier B A/B — external event OWN050 (no ref) -> OWN001 (with --ref-dir)" | |
| # 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`). The POOL005 field pass now also catches a full-length view of an ArrayPool `byte[]` | |
| # buffer FIELD read past its logical length -> OWN025 (`arraypool-field-fullspan-overread`). | |
| # use, and an injected-source region-escape. The DI captive family also has its first real-world | |
| # case now — a singleton injecting a scoped EF `DbContext` -> DI001 (`corpus/di/`, a benchmark-only | |
| # corpus: DI has no `.own` form, so it is not scanned by the Python `test_corpus` runner). | |
| # Remaining backlog: a full-length view STORED into another field, 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 25 | |