From 380b098dd34a8e2de7d58c3a0bcc0e9a90f00739 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:26:49 +0000 Subject: [PATCH] feat: add OWN053 orphaned-awaitable advisory (clean port of the research promotion onto main, strict-door binding included) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWN053 "orphaned awaitable": a local initialised by an un-awaited Task / ValueTask invocation of an effectful operation (the unwrapped result is a real disposable, or the member is a connection / transaction lifecycle call) that is never referenced again in its member. Default-on advisory in both engines with an identical message, rendered as a warning, never the exit code, hidden at --verbosity quiet; no automatic fix. The scope is frozen as measured in ownership-semantics-lab H-29: discards, expression statements, stored / passed / returned awaitables and lambdas are untouched; new lifecycle members enter only by witnesses. Provenance: a re-port onto main 5323dd42 of research/ownership-semantics-lab-v1 commits ab2964f1 (promotion) and 298b3053 (P-OWN053-DOOR), carrying ONLY the OWN053 hunks. Unlike a file-level copy of those two commits, this port does not bring the research branch's content along: the extractor gains one detector (CollectOrphanedAwaitables), one holder and the additive facts branch instead of 1,900 lines of env-gated research seams; ownlang/ownir.py gains the family set, the strict-door block, the check_facts call and the finding builder instead of the H-20 / resource-effects E3 / P-037-X seams; spec/OwnIR.md and the schema gain §9, the §4.2 note, the orphanedAwaitable / orphanFamily definitions and nothing of params[].ordinal or flag_var; tests/test_ownir_validation_fixtures.py keeps main's #383 writer contract and gains the orphaned_awaitables section on top of it; tests/test_p037_evidence.py is untouched because main has none of the research read sites; the Rust diagnostics catalogue and the diagnostics ledger (47 -> 48) are included, which the file-level copy had lost. Extractor: the sites are collected into the ADDITIVE top-level facts list orphaned_awaitables (absent when there is no site, so such a document is byte-identical to the pre-OWN053 shape; ownir_version unchanged). On the committed fixture corpus/ownership-lab/h29/fx/Orphan.cs the production build finds exactly the five frozen primary sites of the H-29 scan and none of the eleven twins; the facts equal the research build's modulo the research-only params[].ordinal field. Strict doors: both load() and the Rust strict door validate the list last in BR-D1 order (array of objects; non-empty local / callee as identity, file string, line in the §4.2 domain, column as every other column, method / result_type string-or-null, family from the closed set as vocabulary). cp1 ledger 294 -> 344 controls, appended insertion-stable on top of #383's writer; the Rust validation replay reports no permissive accept and no category mismatch. Schema bound to sourceLine / sourceColumn / orphanFamily (pinned to ownlang/ownir.py::_ORPHAN_FAMILIES); binding map BOUND; coordinate census door slots; checkpoint documents regenerated. Verification on this tree: the fixture through both CLIs (5 advisory OWN053, 0 findings, exit 0, Python == Rust); three malformed documents refused by both CLIs with exit 2 (corpus/ownership-lab/h29/door); CLI fixtures, verdict goldens (one new synthetic case), diagnostics ledger and repro digests regenerated; full Python suite and cargo test --release green; the extractor builds with main's pre-existing warning only. Not included (registered in Own.NET-paperwork): P-OWN053-WORDING (family-specific message tails) stays open and precedes any family A/B expansion; discards / expression statements / family-B expansion / autofix / H-26A are not earned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Am9eQwzNfbugH72eVKetC2 --- corpus/ownership-lab/h29/door/README.txt | 16 + .../h29/door/neg-family.facts.json | 1 + .../ownership-lab/h29/door/neg-family.py.txt | 1 + .../h29/door/neg-family.rust.txt | 1 + .../h29/door/neg-garbage.facts.json | 1 + .../ownership-lab/h29/door/neg-garbage.py.txt | 1 + .../h29/door/neg-garbage.rust.txt | 1 + .../h29/door/neg-line_above.facts.json | 1 + .../h29/door/neg-line_above.py.txt | 1 + .../h29/door/neg-line_above.rust.txt | 1 + .../h29/door/promo-u.door.py.txt | 7 + .../h29/door/promo-u.door.rust.txt | 7 + corpus/ownership-lab/h29/fx/Orphan.cs | 27 + corpus/ownership-lab/h29/promotion/README.txt | 21 + corpus/ownership-lab/h29/promotion/build.txt | 1 + .../fixture-diff-prototype-vs-promoted.txt | 20 + .../h29/promotion/promo-u.facts.json | 153 +++ .../h29/promotion/promo-u.py.txt | 7 + .../h29/promotion/promo-u.rust.txt | 7 + .../orphaned-awaitable-wolverine.md | 63 + docs/generated/p022-coord-census.md | 55 +- docs/generated/p022-cp1-census.md | 31 +- docs/generated/p022-cp4-census.md | 10 +- docs/generated/p022-cp5-inventory.md | 3 +- docs/generated/p022-shadow-census.md | 8 +- frontend/roslyn/OwnSharp.Extractor/Program.cs | 68 ++ ownlang/__main__.py | 4 +- ownlang/diagnostics.py | 16 + ownlang/ownir.py | 83 ++ rust/crates/own-bridge/src/verdict.rs | 41 + rust/crates/own-cli/src/text.rs | 6 +- rust/crates/own-diagnostics/src/diagnostic.rs | 6 +- rust/crates/own-ir/src/strict.rs | 71 +- spec/Bridge.md | 11 +- spec/BridgeBehaviorMatrix.md | 2 +- spec/Diagnostics.md | 29 + spec/OwnIR.md | 48 +- spec/ownir.schema.json | 26 + tests/coordinate_census.py | 4 + tests/fixtures/cli_ownir/manifest.json | 2 +- ...wnir-help-is-the-declared-defect.case.json | 2 +- ...sage-double-dash-not-a-separator.case.json | 2 +- .../cli_ownir/usage-no-positional.case.json | 2 +- .../cli_ownir/usage-two-positionals.case.json | 2 +- .../usage-unknown-flag-with-path.case.json | 2 +- tests/fixtures/diag_ledger.json | 21 +- tests/fixtures/ownir_validation.json | 1020 ++++++++++++++++- tests/fixtures/repro/digests.json | 6 + tests/fixtures/verdicts/manifest.json | 8 + ...rdict_own053_orphaned_awaitable.facts.json | 46 + ...ct_own053_orphaned_awaitable.verdicts.json | 69 ++ tests/test_cli_ownir_fixtures.py | 2 +- tests/test_ownir.py | 73 +- tests/test_ownir_defensive_limits.py | 20 +- tests/test_ownir_validation_fixtures.py | 199 ++++ tests/verdict_surface_inventory.py | 7 + 56 files changed, 2266 insertions(+), 77 deletions(-) create mode 100644 corpus/ownership-lab/h29/door/README.txt create mode 100644 corpus/ownership-lab/h29/door/neg-family.facts.json create mode 100644 corpus/ownership-lab/h29/door/neg-family.py.txt create mode 100644 corpus/ownership-lab/h29/door/neg-family.rust.txt create mode 100644 corpus/ownership-lab/h29/door/neg-garbage.facts.json create mode 100644 corpus/ownership-lab/h29/door/neg-garbage.py.txt create mode 100644 corpus/ownership-lab/h29/door/neg-garbage.rust.txt create mode 100644 corpus/ownership-lab/h29/door/neg-line_above.facts.json create mode 100644 corpus/ownership-lab/h29/door/neg-line_above.py.txt create mode 100644 corpus/ownership-lab/h29/door/neg-line_above.rust.txt create mode 100644 corpus/ownership-lab/h29/door/promo-u.door.py.txt create mode 100644 corpus/ownership-lab/h29/door/promo-u.door.rust.txt create mode 100644 corpus/ownership-lab/h29/fx/Orphan.cs create mode 100644 corpus/ownership-lab/h29/promotion/README.txt create mode 100644 corpus/ownership-lab/h29/promotion/build.txt create mode 100644 corpus/ownership-lab/h29/promotion/fixture-diff-prototype-vs-promoted.txt create mode 100644 corpus/ownership-lab/h29/promotion/promo-u.facts.json create mode 100644 corpus/ownership-lab/h29/promotion/promo-u.py.txt create mode 100644 corpus/ownership-lab/h29/promotion/promo-u.rust.txt create mode 100644 docs/case-studies/orphaned-awaitable-wolverine.md create mode 100644 tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.facts.json create mode 100644 tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.verdicts.json diff --git a/corpus/ownership-lab/h29/door/README.txt b/corpus/ownership-lab/h29/door/README.txt new file mode 100644 index 00000000..a116c18d --- /dev/null +++ b/corpus/ownership-lab/h29/door/README.txt @@ -0,0 +1,16 @@ +P-OWN053-DOOR: orphaned_awaitables[] bound at both OwnIR strict doors (after the OWN053 promotion; +prereg frozen in Own.NET-paperwork before code). Produced by the PRODUCTION build of this tree, from +the repository root. + +promo-u.door.py.txt / promo-u.door.rust.txt + ../promotion/promo-u.facts.json through both CLIs, whose path IS the strict door: accepted, + 5 advisory OWN053, 0 findings, exit 0, Python == Rust -- the rule and the message did not move. +neg-garbage.facts.json an entry whose local is a list, callee an object, result_type a number + (the exact shape the unbound list rendered as a real OWN053) +neg-line_above.facts.json an entry with line 2147483648 (above the §4.2 domain; the unbound + list degraded it to 0) +neg-family.facts.json an entry with family "C_whatever" (outside the closed set) +neg-*.py.txt / neg-*.rust.txt + both CLIs refuse each document with exit code 2 (ordinary bad input) naming the same rule; + the message text differs by language, as the cp1 ledger allows (verdict + category are the + cross-language contract, tests/fixtures/ownir_validation.json section orphaned_awaitables). diff --git a/corpus/ownership-lab/h29/door/neg-family.facts.json b/corpus/ownership-lab/h29/door/neg-family.facts.json new file mode 100644 index 00000000..f3c2d757 --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-family.facts.json @@ -0,0 +1 @@ +{"ownir_version": 0, "module": "M", "orphaned_awaitables": [{"local": "tx", "callee": "T.M/0", "file": "Q.cs", "line": 1, "family": "C_whatever"}]} \ No newline at end of file diff --git a/corpus/ownership-lab/h29/door/neg-family.py.txt b/corpus/ownership-lab/h29/door/neg-family.py.txt new file mode 100644 index 00000000..3792be5a --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-family.py.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-family.facts.json: error: orphaned awaitable 'family' must be one of ['A_owned_result', 'B_protocol_lifecycle'], got 'C_whatever' diff --git a/corpus/ownership-lab/h29/door/neg-family.rust.txt b/corpus/ownership-lab/h29/door/neg-family.rust.txt new file mode 100644 index 00000000..61397d1c --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-family.rust.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-family.facts.json: error: orphaned awaitable 'family' must be one of ["A_owned_result", "B_protocol_lifecycle"], got "C_whatever" diff --git a/corpus/ownership-lab/h29/door/neg-garbage.facts.json b/corpus/ownership-lab/h29/door/neg-garbage.facts.json new file mode 100644 index 00000000..b7c3c126 --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-garbage.facts.json @@ -0,0 +1 @@ +{"ownir_version": 0, "module": "M", "orphaned_awaitables": [{"local": ["what", "is", "this"], "callee": {"oops": 1}, "file": "Q.cs", "line": 119, "result_type": 12345}]} \ No newline at end of file diff --git a/corpus/ownership-lab/h29/door/neg-garbage.py.txt b/corpus/ownership-lab/h29/door/neg-garbage.py.txt new file mode 100644 index 00000000..71ffe1ea --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-garbage.py.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-garbage.facts.json: error: orphaned awaitable 'local' must be a non-empty string, got ['what', 'is', 'this'] diff --git a/corpus/ownership-lab/h29/door/neg-garbage.rust.txt b/corpus/ownership-lab/h29/door/neg-garbage.rust.txt new file mode 100644 index 00000000..7aad33ec --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-garbage.rust.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-garbage.facts.json: error: orphaned awaitable: 'local' must be a non-empty string diff --git a/corpus/ownership-lab/h29/door/neg-line_above.facts.json b/corpus/ownership-lab/h29/door/neg-line_above.facts.json new file mode 100644 index 00000000..7c09e789 --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-line_above.facts.json @@ -0,0 +1 @@ +{"ownir_version": 0, "module": "M", "orphaned_awaitables": [{"local": "tx", "callee": "T.M/0", "file": "Q.cs", "line": 2147483648}]} \ No newline at end of file diff --git a/corpus/ownership-lab/h29/door/neg-line_above.py.txt b/corpus/ownership-lab/h29/door/neg-line_above.py.txt new file mode 100644 index 00000000..b3e45678 --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-line_above.py.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-line_above.facts.json: error: orphaned awaitable 'line' must be a source line in [0, 2147483647], got 2147483648 (spec/OwnIR.md §4.2) diff --git a/corpus/ownership-lab/h29/door/neg-line_above.rust.txt b/corpus/ownership-lab/h29/door/neg-line_above.rust.txt new file mode 100644 index 00000000..b3e45678 --- /dev/null +++ b/corpus/ownership-lab/h29/door/neg-line_above.rust.txt @@ -0,0 +1 @@ +corpus/ownership-lab/h29/door/neg-line_above.facts.json: error: orphaned awaitable 'line' must be a source line in [0, 2147483647], got 2147483648 (spec/OwnIR.md §4.2) diff --git a/corpus/ownership-lab/h29/door/promo-u.door.py.txt b/corpus/ownership-lab/h29/door/promo-u.door.py.txt new file mode 100644 index 00000000..1d05e8e4 --- /dev/null +++ b/corpus/ownership-lab/h29/door/promo-u.door.py.txt @@ -0,0 +1,7 @@ +corpus/ownership-lab/h29/fx/Orphan.cs:11: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:12: warning: [OWN053] orphaned awaitable: 'r' = Npgsql.NpgsqlCommand.ExecuteReaderAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlDataReader is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:21: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:22: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:23: warning: [OWN053] orphaned awaitable: 'c' = Npgsql.NpgsqlTransaction.CommitAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] + +0 findings, 5 advisory (OWN053). diff --git a/corpus/ownership-lab/h29/door/promo-u.door.rust.txt b/corpus/ownership-lab/h29/door/promo-u.door.rust.txt new file mode 100644 index 00000000..1d05e8e4 --- /dev/null +++ b/corpus/ownership-lab/h29/door/promo-u.door.rust.txt @@ -0,0 +1,7 @@ +corpus/ownership-lab/h29/fx/Orphan.cs:11: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:12: warning: [OWN053] orphaned awaitable: 'r' = Npgsql.NpgsqlCommand.ExecuteReaderAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlDataReader is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:21: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:22: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:23: warning: [OWN053] orphaned awaitable: 'c' = Npgsql.NpgsqlTransaction.CommitAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] + +0 findings, 5 advisory (OWN053). diff --git a/corpus/ownership-lab/h29/fx/Orphan.cs b/corpus/ownership-lab/h29/fx/Orphan.cs new file mode 100644 index 00000000..fb725dd0 --- /dev/null +++ b/corpus/ownership-lab/h29/fx/Orphan.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +// H-29 fixture: orphaned awaitables and their twins; the method name carries the expectation (family / form / later references). +public class Orphan +{ + Task _kept; Stream _s = null; + public async Task O01_B_orphan(NpgsqlConnection conn, CancellationToken ct) { var tx = conn.BeginTransactionAsync(ct); await Task.Yield(); } // local, B, refs 0 (PRIMARY) + public async Task O02_A_orphan(NpgsqlCommand cmd) { var r = cmd.ExecuteReaderAsync(); await Task.Yield(); } // local, A, refs 0 (PRIMARY) + public async Task O03_observed_later(NpgsqlConnection conn, CancellationToken ct) { var tx = conn.BeginTransactionAsync(ct); await using var t = await tx; } // refs 1 (not a candidate) + public void O04_stored(NpgsqlCommand cmd) { var t = cmd.ExecuteNonQueryAsync(); _kept = t; } // refs 1 + public async Task O05_passed(NpgsqlCommand cmd) { var t = cmd.ExecuteNonQueryAsync(); await Task.WhenAll(t); } // refs 1 + public void O06_discard(NpgsqlConnection conn, CancellationToken ct) { _ = conn.BeginTransactionAsync(ct); } // discard, B + public void O07_statement(NpgsqlConnection conn, CancellationToken ct) { conn.BeginTransactionAsync(ct); } // statement, B + public async Task O08_other_delay() { var d = Task.Delay(1); await Task.Yield(); } // local, OTHER, refs 0 + public async Task O09_other_nonquery(NpgsqlCommand cmd) { var n = cmd.ExecuteNonQueryAsync(); await Task.Yield(); } // local, OTHER, refs 0 + public async Task O10_awaited(NpgsqlCommand cmd) { var r = await cmd.ExecuteReaderAsync(); await r.DisposeAsync(); } // not an awaitable local (awaited) + public async Task O11_B_configureawait(NpgsqlConnection conn, CancellationToken ct) { var tx = conn.BeginTransactionAsync(ct).ConfigureAwait(false); await Task.Yield(); } // local, B via ConfigureAwait, refs 0 (PRIMARY) + public async Task O12_in_try(NpgsqlConnection conn, CancellationToken ct) { try { var tx = conn.BeginTransactionAsync(ct); await Task.Yield(); } finally { await conn.CloseAsync(); } } // local, B, refs 0, in_try (PRIMARY, the real shape) + public void O13_B_sync_commit(NpgsqlTransaction t) { var c = t.CommitAsync(); } // local, B (non-generic Task), refs 0 (PRIMARY) + public async Task O14_lambda(NpgsqlConnection conn, CancellationToken ct) { Func f = async () => { var tx = conn.BeginTransactionAsync(ct); await Task.Yield(); }; await f(); } // in_lambda flag + public async Task O15_A_stream(Stream s) { var r = s.ReadAsync(new byte[1], 0, 1); await Task.Yield(); } // local, OTHER (int result), refs 0 + public async Task O16_using_local(NpgsqlCommand cmd) { await using var r = await cmd.ExecuteReaderAsync(); } // using local: skipped +} diff --git a/corpus/ownership-lab/h29/promotion/README.txt b/corpus/ownership-lab/h29/promotion/README.txt new file mode 100644 index 00000000..0ca8b54f --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/README.txt @@ -0,0 +1,21 @@ +ownership-semantics-lab H-29 -> OWN053 promotion: the fixture run on the PRODUCTION build. + +Build: frontend/roslyn/OwnSharp.Extractor of this tree (net8.0, Release), the rule always on under +--flow-locals (the own-check default). Run from the repository root on the committed fixture, with +the H-29 falsifier's Npgsql build output as the reference directory (Npgsql 10.0.3; any directory +holding that Npgsql.dll gives the same facts): + dotnet ownsharp-extract.dll --flow-locals corpus/ownership-lab/h29/fx/Orphan.cs --ref-dir -o promo-u.facts.json + python -m ownlang ownir promo-u.facts.json > promo-u.py.txt + own-cli ownir promo-u.facts.json > promo-u.rust.txt + +promo-u.facts.json the production build's facts (file paths repository-relative) +promo-u.py.txt / promo-u.rust.txt both engines: identical (parity), 5 advisory OWN053 at + Orphan.cs 11 / 12 / 21 / 22 / 23 (O01, O02, O11, O12, O13 -- + exactly the five frozen primary sites of the H-29 scan), 0 + findings, exit code 0; the eleven twins silent +fixture-diff-prototype-vs-promoted.txt research-branch provenance: the diff between the OWEN_H29=1 + prototype build's facts and the promoted research build's + (only the entries' file-path form moved); both builds live on + research/ownership-semantics-lab-v1, whose facts also carry + the research-only params[].ordinal field this tree does not emit +build.txt the extractor's stderr for the run above diff --git a/corpus/ownership-lab/h29/promotion/build.txt b/corpus/ownership-lab/h29/promotion/build.txt new file mode 100644 index 00000000..3cc4bea4 --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/build.txt @@ -0,0 +1 @@ +extractor: +4 references from --ref-dir /tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/falsifier/bin/Release/net8.0 (recursive) diff --git a/corpus/ownership-lab/h29/promotion/fixture-diff-prototype-vs-promoted.txt b/corpus/ownership-lab/h29/promotion/fixture-diff-prototype-vs-promoted.txt new file mode 100644 index 00000000..600b3a55 --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/fixture-diff-prototype-vs-promoted.txt @@ -0,0 +1,20 @@ +106c106 +< "file": "/tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +--- +> "file": "../../../tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +116c116 +< "file": "/tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +--- +> "file": "../../../tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +126c126 +< "file": "/tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +--- +> "file": "../../../tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +136c136 +< "file": "/tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +--- +> "file": "../../../tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +146c146 +< "file": "/tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", +--- +> "file": "../../../tmp/claude-0/-home-user/8a9da608-aa27-5449-8306-1fae9426f9b9/scratchpad/lab/h29/fx/Orphan.cs", diff --git a/corpus/ownership-lab/h29/promotion/promo-u.facts.json b/corpus/ownership-lab/h29/promotion/promo-u.facts.json new file mode 100644 index 00000000..7b90eb3a --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/promo-u.facts.json @@ -0,0 +1,153 @@ +{ + "ownir_version": 0, + "module": "Extracted", + "components": [], + "services": [], + "functions": [ + { + "name": "Orphan.O06_discard", + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "sig": "Npgsql.NpgsqlConnection,System.Threading.CancellationToken", + "params": [ + { + "name": "conn", + "line": 16 + } + ], + "body": [ + { + "op": "use", + "var": "conn", + "line": 16 + } + ] + }, + { + "name": "Orphan.O07_statement", + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "sig": "Npgsql.NpgsqlConnection,System.Threading.CancellationToken", + "params": [ + { + "name": "conn", + "line": 17 + } + ], + "body": [ + { + "op": "use", + "var": "conn", + "line": 17 + } + ] + }, + { + "name": "Orphan.O12_in_try", + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "sig": "Npgsql.NpgsqlConnection,System.Threading.CancellationToken", + "params": [ + { + "name": "conn", + "line": 22 + } + ], + "body": [ + { + "op": "if", + "line": 22, + "then": [ + { + "op": "use", + "var": "conn", + "line": 22 + }, + { + "op": "return", + "var": null, + "line": 22 + } + ], + "else": [] + }, + { + "op": "if", + "line": 22, + "then": [ + { + "op": "use", + "var": "conn", + "line": 22 + }, + { + "op": "return", + "var": null, + "line": 22 + } + ], + "else": [] + }, + { + "op": "use", + "var": "conn", + "line": 22 + } + ] + } + ], + "stats": { + "methods_with_local": 15, + "methods_flow_analysed": 3, + "methods_skipped_unmodelled": 12 + }, + "orphaned_awaitables": [ + { + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "line": 11, + "column": 87, + "method": "Orphan.O01_B_orphan", + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", + "result_type": "Npgsql.NpgsqlTransaction" + }, + { + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "line": 12, + "column": 61, + "method": "Orphan.O02_A_orphan", + "local": "r", + "callee": "Npgsql.NpgsqlCommand.ExecuteReaderAsync/1", + "family": "A_owned_result", + "result_type": "Npgsql.NpgsqlDataReader" + }, + { + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "line": 21, + "column": 95, + "method": "Orphan.O11_B_configureawait", + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", + "result_type": "Npgsql.NpgsqlTransaction" + }, + { + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "line": 22, + "column": 91, + "method": "Orphan.O12_in_try", + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", + "result_type": "Npgsql.NpgsqlTransaction" + }, + { + "file": "corpus/ownership-lab/h29/fx/Orphan.cs", + "line": 23, + "column": 62, + "method": "Orphan.O13_B_sync_commit", + "local": "c", + "callee": "Npgsql.NpgsqlTransaction.CommitAsync/1", + "family": "B_protocol_lifecycle", + "result_type": null + } + ] +} \ No newline at end of file diff --git a/corpus/ownership-lab/h29/promotion/promo-u.py.txt b/corpus/ownership-lab/h29/promotion/promo-u.py.txt new file mode 100644 index 00000000..1d05e8e4 --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/promo-u.py.txt @@ -0,0 +1,7 @@ +corpus/ownership-lab/h29/fx/Orphan.cs:11: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:12: warning: [OWN053] orphaned awaitable: 'r' = Npgsql.NpgsqlCommand.ExecuteReaderAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlDataReader is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:21: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:22: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:23: warning: [OWN053] orphaned awaitable: 'c' = Npgsql.NpgsqlTransaction.CommitAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] + +0 findings, 5 advisory (OWN053). diff --git a/corpus/ownership-lab/h29/promotion/promo-u.rust.txt b/corpus/ownership-lab/h29/promotion/promo-u.rust.txt new file mode 100644 index 00000000..1d05e8e4 --- /dev/null +++ b/corpus/ownership-lab/h29/promotion/promo-u.rust.txt @@ -0,0 +1,7 @@ +corpus/ownership-lab/h29/fx/Orphan.cs:11: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:12: warning: [OWN053] orphaned awaitable: 'r' = Npgsql.NpgsqlCommand.ExecuteReaderAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlDataReader is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:21: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:22: warning: [OWN053] orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] +corpus/ownership-lab/h29/fx/Orphan.cs:23: warning: [OWN053] orphaned awaitable: 'c' = Npgsql.NpgsqlTransaction.CommitAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly [resource: orphaned awaitable] + +0 findings, 5 advisory (OWN053). diff --git a/docs/case-studies/orphaned-awaitable-wolverine.md b/docs/case-studies/orphaned-awaitable-wolverine.md new file mode 100644 index 00000000..18653dc4 --- /dev/null +++ b/docs/case-studies/orphaned-awaitable-wolverine.md @@ -0,0 +1,63 @@ +# Case study: the orphaned awaitable (OWN053) and Wolverine's `ScheduleRetryAsync` + +**What the analyzer saw.** Scanning JasperFx/wolverine (a frozen benchmark +consumer, main at `7ee3df90` / `c20fb0cc`) the OwnIR extractor found exactly one +production local in 772 project/TFM units that is initialised by an un-awaited, +effectful awaitable and never referenced again: + +```csharp +// src/Persistence/Wolverine.Postgresql/Transport/PostgresqlQueueSender.cs +try +{ + var tx = conn.BeginTransactionAsync(cancellationToken); // never awaited, never read + await scheduleMessageAsync(envelope, cancellationToken, conn); +} +finally +{ + await conn.CloseAsync(); +} +``` + +**Why it matters (measured, not assumed).** Against Npgsql 10.0.3 and PostgreSQL 16: + +- `BeginTransactionAsync` completes synchronously (`IsCompleted == true` right + after the call) and changes the connection state inline, whether or not the + returned `ValueTask` is ever awaited. +- Every later command on the connection runs inside that transaction; a second + `BeginTransactionAsync` throws "a transaction is already in progress". +- Nobody holds the transaction, so nothing commits or disposes it; closing the + connection rolls the work back. A probe that inserts after the orphaned call + and closes the connection loses the insert. + +**Why the message is not lost today.** Driving the real `PostgresqlQueueSender` +with an incoming row present gives `incoming = 0, scheduled = 1` after the call: +the command right before the `try` is one autocommitted batch (`delete from +incoming ...; insert into scheduled ... on conflict do update`) that already moves +the message. The write inside the orphaned transaction is a redundant second upsert +whose rollback is invisible. The defect is real — an unobserved acquisition, a +redundant rolled-back write, and a latent trap for any write placed after the +orphan — but its current impact is robustness, not data loss. The first +hand-written "exact shape" probe omitted that batch and overstated the impact; +the correction is recorded (erratum 5 in the research paperwork) and is the +reason the upstream report describes the measured behaviour only. + +**Why nothing else reported it.** CA2012, VSTHRD110, MA0134 and CS4014 are silent +by design: assigning the task to a local counts as "observing it later". CS0219 is +not issued for a method-call result. IDE0059 reports the pattern in plain and +async shapes but goes silent inside `try` / `finally` — exactly this site's shape. + +**What Own.NET does differently.** OWN053 looks at the *subsequent life of the +acquired protocol object*, not at the statement form: an awaitable whose result or +completion carries an obligation (an owned result, or a connection / transaction +lifecycle call) that is obtained and then never awaited, returned, stored, passed +or otherwise observed. It is deliberately narrow — discards and bare statements +belong to other rules, non-effectful awaitables are out of scope, and new +lifecycle names enter only with a runtime witness — and it offers no automatic +fix: only the author knows whether the call should be awaited, kept, or made an +explicit fire-and-forget. + +**Evidence trail.** Preregistration before any code, an exact Npgsql runtime +falsifier, the analyzer-overlap measurement, an 8-consumer census (one orphan), +and an OFF/ON scan of the prototype over 766 units with Python/Rust parity and +byte-identical OFF facts; the records live in the Own.NET-paperwork repository +(`paper-eval/h29/`), the probes and drivers in `corpus/ownership-lab/h29/`. diff --git a/docs/generated/p022-coord-census.md b/docs/generated/p022-coord-census.md index 3f4a526a..e23313d7 100644 --- a/docs/generated/p022-coord-census.md +++ b/docs/generated/p022-coord-census.md @@ -10,23 +10,23 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | measure | value | |------------------------------------|------:| -| JSON files scanned | 438 | -| coordinate slots found | 2197 | +| JSON files scanned | 440 | +| coordinate slots found | 2267 | ## By value class | value class | all slots | door slots | |---|---:|---:| -| `above-int32` | 21 | 21 | -| `below-1` | 19 | 19 | -| `bool` | 14 | 14 | -| `float` | 2 | 2 | -| `in-domain` | 1752 | 957 | -| `negative` | 23 | 23 | -| `null` | 227 | 7 | -| `outside-int64` | 14 | 14 | -| `string` | 17 | 17 | -| `zero` | 108 | 25 | +| `above-int32` | 24 | 24 | +| `below-1` | 23 | 23 | +| `bool` | 17 | 17 | +| `float` | 3 | 3 | +| `in-domain` | 1799 | 998 | +| `negative` | 26 | 26 | +| `null` | 231 | 9 | +| `outside-int64` | 15 | 15 | +| `string` | 19 | 19 | +| `zero` | 110 | 26 | ## By family and slot @@ -35,7 +35,7 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | family | slot | class | door | count | files | values | |---|---|---|:--:|---:|---:|---| | `(root)` | `cases[].diagnostic.evidence[].line` | `in-domain` | — | 40 | 1 | — | -| `(root)` | `cases[].diagnostic.line` | `in-domain` | — | 47 | 1 | — | +| `(root)` | `cases[].diagnostic.line` | `in-domain` | — | 48 | 1 | — | | `(root)` | `cases[].diagnostics[].evidence[].line` | `in-domain` | — | 9 | 1 | — | | `(root)` | `cases[].diagnostics[].evidence[].line` | `zero` | — | 1 | 1 | — | | `(root)` | `cases[].diagnostics[].line` | `in-domain` | — | 20 | 1 | — | @@ -89,6 +89,21 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `(root)` | `cases[].document.functions[].params[].line` | `outside-int64` | yes | 1 | 1 | `9223372036854775808` | | `(root)` | `cases[].document.functions[].params[].line` | `string` | yes | 3 | 1 | `'3'` | | `(root)` | `cases[].document.functions[].params[].line` | `zero` | yes | 1 | 1 | — | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `above-int32` | yes | 1 | 1 | `2147483648` | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `below-1` | yes | 3 | 1 | `-1`, `0` | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `bool` | yes | 2 | 1 | `True` | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `in-domain` | yes | 3 | 1 | — | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `null` | yes | 1 | 1 | — | +| `(root)` | `cases[].document.orphaned_awaitables[].column` | `string` | yes | 1 | 1 | `'17'` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `above-int32` | yes | 1 | 1 | `2147483648` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `bool` | yes | 1 | 1 | `True` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `float` | yes | 1 | 1 | `1.5` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `in-domain` | yes | 33 | 1 | — | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `negative` | yes | 3 | 1 | `-1` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `null` | yes | 1 | 1 | — | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `outside-int64` | yes | 1 | 1 | `-9223372036854775809` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `string` | yes | 1 | 1 | `'119'` | +| `(root)` | `cases[].document.orphaned_awaitables[].line` | `zero` | yes | 1 | 1 | — | | `(root)` | `cases[].document.protocol_functions[].events[].[].line` | `in-domain` | yes | 134 | 1 | — | | `(root)` | `cases[].document.protocol_functions[].events[].line` | `above-int32` | yes | 1 | 1 | `2147483648` | | `(root)` | `cases[].document.protocol_functions[].events[].line` | `bool` | yes | 1 | 1 | `True` | @@ -192,17 +207,17 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `verdict_renders` | `services[].line` | `in-domain` | yes | 3 | 1 | — | | `verdicts` | `components[].subscriptions[].column` | `above-int32` | yes | 1 | 1 | `2147483648` | | `verdicts` | `components[].subscriptions[].column` | `in-domain` | yes | 2 | 2 | — | -| `verdicts` | `components[].subscriptions[].line` | `in-domain` | yes | 38 | 12 | — | +| `verdicts` | `components[].subscriptions[].line` | `in-domain` | yes | 39 | 13 | — | | `verdicts` | `components[].subscriptions[].line` | `zero` | yes | 1 | 1 | — | | `verdicts` | `effects[].bindings[].line` | `in-domain` | yes | 12 | 6 | — | | `verdicts` | `effects[].bindings[].line` | `zero` | yes | 1 | 1 | — | | `verdicts` | `effects[].line` | `in-domain` | yes | 9 | 5 | — | | `verdicts` | `effects[].line` | `negative` | yes | 1 | 1 | `-3` | | `verdicts` | `effects[].line` | `zero` | yes | 1 | 1 | — | -| `verdicts` | `findings[].column` | `in-domain` | — | 14 | 5 | — | -| `verdicts` | `findings[].column` | `null` | — | 168 | 74 | — | -| `verdicts` | `findings[].line` | `in-domain` | — | 168 | 69 | — | -| `verdicts` | `findings[].line` | `zero` | — | 14 | 11 | — | +| `verdicts` | `findings[].column` | `in-domain` | — | 16 | 6 | — | +| `verdicts` | `findings[].column` | `null` | — | 170 | 75 | — | +| `verdicts` | `findings[].line` | `in-domain` | — | 171 | 70 | — | +| `verdicts` | `findings[].line` | `zero` | — | 15 | 12 | — | | `verdicts` | `functions[].[].column` | `below-1` | yes | 1 | 1 | `0` | | `verdicts` | `functions[].[].column` | `in-domain` | yes | 7 | 2 | — | | `verdicts` | `functions[].[].line` | `above-int32` | yes | 1 | 1 | `4294967296` | @@ -210,6 +225,10 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `verdicts` | `functions[].[].line` | `zero` | yes | 1 | 1 | — | | `verdicts` | `functions[].params[].line` | `in-domain` | yes | 3 | 2 | — | | `verdicts` | `functions[].params[].line` | `negative` | yes | 1 | 1 | `-1` | +| `verdicts` | `orphaned_awaitables[].column` | `below-1` | yes | 1 | 1 | `0` | +| `verdicts` | `orphaned_awaitables[].column` | `in-domain` | yes | 2 | 1 | — | +| `verdicts` | `orphaned_awaitables[].line` | `above-int32` | yes | 1 | 1 | `2147483648` | +| `verdicts` | `orphaned_awaitables[].line` | `in-domain` | yes | 2 | 1 | — | | `verdicts` | `protocol_functions[].events[].[].line` | `in-domain` | yes | 2 | 2 | — | | `verdicts` | `protocol_functions[].events[].line` | `in-domain` | yes | 34 | 7 | — | | `verdicts` | `services[].ctor_line` | `in-domain` | yes | 3 | 3 | — | diff --git a/docs/generated/p022-cp1-census.md b/docs/generated/p022-cp1-census.md index a77a3945..bb6b7367 100644 --- a/docs/generated/p022-cp1-census.md +++ b/docs/generated/p022-cp1-census.md @@ -8,9 +8,9 @@ | measure | value | |--------------------------------|------:| -| controls | 294 | -| … accepted | 60 | -| … rejected | 234 | +| controls | 344 | +| … accepted | 69 | +| … rejected | 275 | ## By category @@ -18,13 +18,13 @@ Seven categories on two axes (`shape` is "no representable primitive or containe | category | controls | what it means | |---|---:|---| -| `accepted` | 60 | the document is accepted | -| `identity` | 22 | a name slot — empty, mistyped, or duplicated | +| `accepted` | 69 | the document is accepted | +| `identity` | 33 | a name slot — empty, mistyped, or duplicated | | `json` | 2 | the document is not JSON at all | -| `location` | 50 | a REPRESENTABLE source coordinate violating its coordinate-domain rule — the 1-based column, and the int32 line/column domain of spec/OwnIR.md §4.2 | -| `shape` | 132 | right place, but the value has no representable primitive or container form the contract requires | +| `location` | 57 | a REPRESENTABLE source coordinate violating its coordinate-domain rule — the 1-based column, and the int32 line/column domain of spec/OwnIR.md §4.2 | +| `shape` | 151 | right place, but the value has no representable primitive or container form the contract requires | | `version` | 7 | the `ownir_version` gate — type or value | -| `vocabulary` | 19 | right JSON type, value outside a closed set | +| `vocabulary` | 23 | right JSON type, value outside a closed set | | `well_formedness` | 2 | right types, legal vocabulary, and the record still cannot mean anything | ## By section @@ -37,7 +37,8 @@ The ledger's own grouping, which is BR-D1's check order. A section with acceptan | `effects` | 4 | 16 | `location` 4, `shape` 12 | | `functions` | 21 | 50 | `identity` 2, `location` 22, `shape` 25, `vocabulary` 1 | | `json` | 0 | 1 | `json` 1 | -| `order` | 0 | 25 | `identity` 5, `json` 1, `location` 4, `shape` 9, `version` 2, `vocabulary` 4 | +| `order` | 0 | 27 | `identity` 7, `json` 1, `location` 4, `shape` 9, `version` 2, `vocabulary` 4 | +| `orphaned_awaitables` | 9 | 39 | `identity` 9, `location` 7, `shape` 19, `vocabulary` 4 | | `protocol_functions` | 5 | 25 | `identity` 5, `location` 2, `shape` 13, `vocabulary` 5 | | `protocols` | 6 | 27 | `identity` 7, `shape` 16, `vocabulary` 2, `well_formedness` 2 | | `root` | 3 | 9 | `shape` 9 | @@ -50,9 +51,9 @@ Every control whose document carries a `line`, `ctor_line` or `column` at any de | measure | value | |------------------------------------|------:| -| coordinate-bearing controls | 151 | -| … accepted | 39 | -| … rejected `identity` | 1 | -| … rejected `location` | 50 | -| … rejected `shape` | 57 | -| … rejected `vocabulary` | 4 | +| coordinate-bearing controls | 193 | +| … accepted | 47 | +| … rejected `identity` | 11 | +| … rejected `location` | 57 | +| … rejected `shape` | 70 | +| … rejected `vocabulary` | 8 | diff --git a/docs/generated/p022-cp4-census.md b/docs/generated/p022-cp4-census.md index 26ced391..b3f6a2ef 100644 --- a/docs/generated/p022-cp4-census.md +++ b/docs/generated/p022-cp4-census.md @@ -8,18 +8,18 @@ Computed by `tests/verdict_census.py` and `tests/verdict_render_census.py` (the | measure | value | |-------------------------------------------------------------------------|------:| -| goldens — Python's complete truth, one per planned case | 95 | +| goldens — Python's complete truth, one per planned case | 96 | | … swept from `tests/fixtures/ownir` | 22 | | … swept from `tests/fixtures/lowered` | 27 | | … swept from `tests/fixtures/summaries` | 9 | -| … synthetic controls (`manifest.json` cases) | 37 | +| … synthetic controls (`manifest.json` cases) | 38 | | reference refusals over all goldens | 5 | -| reference findings over all goldens | 182 | +| reference findings over all goldens | 186 | | declared Rust exclusions — the executable ledger `rust_replay_excluded` | 2 | | … refused at the typed `OwnIr` door (#294 OD-1) | 2 | -| replayed by Rust (goldens minus exclusions) | 93 | +| replayed by Rust (goldens minus exclusions) | 94 | | … reference refusals among them (compared in full) | 5 | -| … findings among them (compared on every `Finding` member) | 180 | +| … findings among them (compared on every `Finding` member) | 184 | The differential counts over the replayed set — Python-only, Rust-only, changed, ordering-only, unexplained — are asserted, not measured here: the Rust replay compares every replayed case's full ordered verdict list (or its refusal text) against the golden on every member, collects every divergence without fail-fast, and fails if one exists. A green `cargo test -p own-bridge --test verdicts` is 0 / 0 / 0 / 0 / 0 by construction; a non-zero count is a red build. diff --git a/docs/generated/p022-cp5-inventory.md b/docs/generated/p022-cp5-inventory.md index 4e461122..e59a24e4 100644 --- a/docs/generated/p022-cp5-inventory.md +++ b/docs/generated/p022-cp5-inventory.md @@ -44,9 +44,10 @@ Checkpoint 4 proved identity, anchor, kind and tiering over the replayed set ([c | `token_subscription_injected_lambda` | bridge | plain `+=` subscription, injected source, inline lambda | 1 | 1 | | `token_subscription_other` | bridge | plain `+=` subscription, any other source | 17 | 17 | | `token_subscription_other_lambda` | bridge | plain `+=` subscription, any other source, inline lambda | 1 | 1 | -| `advisory_own050` | bridge | OWN050 unresolved-reference note | 5 | 5 | +| `advisory_own050` | bridge | OWN050 unresolved-reference note | 6 | 6 | | `advisory_own051` | bridge | OWN051 unverified-transfer note | 6 | 6 | | `advisory_own052` | bridge | OWN052 degraded-inference note | 1 | 1 | +| `advisory_own053` | bridge | OWN053 orphaned-awaitable note | 3 | 3 | | `di001_message` | core-analysis | DI001 captive message (di.py) | 12 | 11 | | `di002_message` | core-analysis | DI002 weak-captive message (di.py) | 2 | 2 | | `di003_message` | core-analysis | DI003 captured-transient message (di.py) | 1 | 1 | diff --git a/docs/generated/p022-shadow-census.md b/docs/generated/p022-shadow-census.md index d6d34f20..1f007f08 100644 --- a/docs/generated/p022-shadow-census.md +++ b/docs/generated/p022-shadow-census.md @@ -28,8 +28,8 @@ acceptance work. | `tests/fixtures/ownir` | 22 | | `tests/fixtures/repro` | 3 | | `tests/fixtures/summaries` | 9 | -| `tests/fixtures/verdicts` | 37 | -| **total** | **98** | +| `tests/fixtures/verdicts` | 38 | +| **total** | **99** | Every one of those documents is canonicalized and hashed by the reference (`ownlang/repro.py`) and re-hashed from the same file by the port @@ -43,8 +43,8 @@ refuses to carry a foreign entry that has none rather than filling one in. | surface | count | |---|---| -| documents captured and digest-pinned | 98 | -| tamper controls (one changed character per document, refusal required) | 98 | +| documents captured and digest-pinned | 99 | +| tamper controls (one changed character per document, refusal required) | 99 | | documents both engines must REFUSE to name (`domain_refusals`) | 6 | | reproduction artifacts committed and replayed byte-for-byte | 10 | | structural negative controls on `verify` (each side) | 34 | diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 3d64e9c2..4d75cd46 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -486,6 +486,49 @@ static IEnumerable Expand(IEnumerable roots) // A finding's file is reported relative to the current directory (the repo root // in CI / under the Action), with forward slashes — so a GitHub annotation or an // MSBuild diagnostic points at the right file even when two files share a name. +// ===== OWN053 "orphaned awaitable" (promoted from ownership-semantics-lab H-29; Own.NET-paperwork h29-prereg-v1.json, +// h29-rule-prereg-v1.json, h29-promotion-v1.json, p-own053-door-v1.json). For every local declared with an UN-AWAITED +// invocation returning Task / Task / ValueTask / ValueTask (through parentheses / ConfigureAwait) whose operation +// is effectful — the unwrapped result is a real disposable (family A) or the member is a connection / transaction / +// persistence lifecycle call (family B) — and that is never referenced again in its member, nor declared inside a +// lambda / local function, one entry goes into the ADDITIVE top-level facts list `orphaned_awaitables`, from which +// both engines mint the advisory OWN053. The scope is frozen as measured: discards (`_ = …`), bare statements and +// stored / passed / returned awaitables stay out, the family vocabulary grows only by witnesses, nothing is lowered. +static void CollectOrphanedAwaitables(BlockSyntax mbody, SemanticModel model) +{ + static bool IsAwaitable(ITypeSymbol? t) => t is INamedTypeSymbol n && ((n.ContainingNamespace?.ToString() == "System.Threading.Tasks" && n.Name is "Task" or "ValueTask") || (n.ContainingNamespace?.ToString() == "System.Runtime.CompilerServices" && n.Name is "ConfiguredTaskAwaitable" or "ConfiguredValueTaskAwaitable")); + static ITypeSymbol? Result(ITypeSymbol? t) => t is INamedTypeSymbol { IsGenericType: true } n ? n.TypeArguments[0] : null; + static bool Lifecycle(string n) => n is "BeginTransaction" or "BeginTransactionAsync" or "Commit" or "CommitAsync" or "Rollback" or "RollbackAsync" or "Open" or "OpenAsync" or "Close" or "CloseAsync" or "DisposeAsync" or "SaveChangesAsync" or "FlushAsync"; + static string Key(IMethodSymbol m) => $"{m.ContainingType.ToDisplayString()}.{m.Name}/{m.Parameters.Length}"; + // the invocation behind an awaitable expression: strip parentheses and a trailing .ConfigureAwait(...) + static InvocationExpressionSyntax? Core(ExpressionSyntax e) + { + while (e is ParenthesizedExpressionSyntax p) e = p.Expression; + if (e is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "ConfigureAwait" } cma }) { e = cma.Expression; while (e is ParenthesizedExpressionSyntax p2) e = p2.Expression; } + return e as InvocationExpressionSyntax; + } + foreach (var ld in mbody.DescendantNodes().OfType()) + { + if (ld.UsingKeyword.RawKind > 0) continue; + foreach (var v in ld.Declaration.Variables) + { + if (v.Initializer?.Value is not { } init || model.GetDeclaredSymbol(v) is not ILocalSymbol ls) continue; + if (!IsAwaitable(model.GetTypeInfo(init).Type)) continue; + var inv = Core(init); if (inv is null || model.GetSymbolInfo(inv).Symbol is not IMethodSymbol m) continue; + var res = Result(m.ReturnType); + var fam = res is not null && ImplementsIDisposable(res) && !IsDisposeOptional(res) && !HasEmptyDisposeBody(res) ? "A_owned_result" : Lifecycle(m.Name) ? "B_protocol_lifecycle" : null; + if (fam is null) continue; + // zero later references in the member: the strict primary of the frozen scope + var refs = mbody.DescendantNodes().OfType().Count(id => id.Identifier.Text == ls.Name && SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(id).Symbol, ls)); + if (refs != 0) continue; + if (v.Ancestors().TakeWhile(a => a != mbody).Any(a => a is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax)) continue; + var member = v.Ancestors().OfType().FirstOrDefault(); + var pos = PosOf(v); + lock (OrphanedAwaitables.Sites) OrphanedAwaitables.Sites.Add(new { file = Rel(v.SyntaxTree.FilePath), line = pos.Line, column = pos.Column, method = member is BaseMethodDeclarationSyntax bmd ? FlowFunctionName(bmd, "?", model) : (member?.Kind().ToString() ?? "?"), local = ls.Name, callee = Key(m), family = fam, result_type = res?.ToDisplayString() }); + } + } +} + static string Rel(string path) => Path.GetRelativePath(Directory.GetCurrentDirectory(), path).Replace('\\', '/'); @@ -6991,6 +7034,9 @@ or ImplicitObjectCreationExpressionSyntax } init // pool / BCL-factory initializers keep their existing classification. candidates.Add(v.Identifier.Text); } + // OWN053 (promoted from ownership-semantics-lab H-29): the orphaned-awaitable sites of this body, + // always on under --flow-locals (the own-check default); the frozen scope is the detector's own comment. + CollectOrphanedAwaitables(mbody, model); // `using (IMemoryOwner owner = MemoryPool.Rent(...)) { … }` STATEMENT form: track the owner // too, so its returned view dangles after the scope-exit dispose (the desugar mirrors the // `using` DECLARATION form handled in the loop above). @@ -7227,6 +7273,20 @@ or ImplicitObjectCreationExpressionSyntax } init functions = flowFunctions, stats = factStats, } + // OWN053 (promoted from ownership-semantics-lab H-29): an ADDITIVE top-level list of orphaned awaitables from which + // both engines mint the advisory; absent when there is no site, so such a document stays byte-identical to the + // pre-OWN053 shape (and `ownir_version` stays 0: the field is additive, like `fix_candidates_version`). + : OrphanedAwaitables.Sites.Count > 0 + ? new + { + ownir_version = 0, + module = "Extracted", + components, + services = factServices, + functions = flowFunctions, + stats = factStats, + orphaned_awaitables = OrphanedAwaitables.Sites.OrderBy(o => JsonSerializer.Serialize(o), StringComparer.Ordinal).ToList(), + } : new { ownir_version = 0, @@ -7294,6 +7354,14 @@ partial class Program // which also serializes `end_line`/`end_column`. Two types rather than one so the fix // block and the anchors keep sharing one definition instead of forking a second // "compute the position of a node" path — which is precisely what this change removes. + // OWN053 (promoted from ownership-semantics-lab H-29): the orphaned-awaitable sites of a run, collected by + // CollectOrphanedAwaitables under --flow-locals and serialized as the ADDITIVE top-level facts list + // `orphaned_awaitables` (absent when empty, so such a document is byte-identical to the pre-OWN053 shape). + static class OrphanedAwaitables + { + internal static readonly List Sites = new(); + } + internal readonly record struct SourcePos(int Line, int Column); internal readonly record struct SourceRange(SourcePos Start, SourcePos End); diff --git a/ownlang/__main__.py b/ownlang/__main__.py index d32ff3b9..b6b555cc 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -30,8 +30,8 @@ the finding is still the core's verdict. `--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes: OWN050 "leakage analysis skipped", OWN051 "ownership transfer unverified", -OWN052 "summaries skipped"), `normal` (default), or `verbose` (also print a -per-code breakdown). +OWN052 "summaries skipped", OWN053 "orphaned awaitable"), `normal` (default), or +`verbose` (also print a per-code breakdown). Exit code is non-zero if any error-level diagnostic was produced. """ diff --git a/ownlang/diagnostics.py b/ownlang/diagnostics.py index 24b55b81..ed92c2d9 100644 --- a/ownlang/diagnostics.py +++ b/ownlang/diagnostics.py @@ -85,6 +85,7 @@ class Severity(Enum): "OWN050": "declaring type unresolved -- leakage analysis skipped", "OWN051": "ownership transfer unverified -- local not checked past this call", "OWN052": "interprocedural summary inference failed -- method summaries skipped", + "OWN053": "orphaned awaitable -- effectful async operation assigned but never observed", # ---- DI container lifetimes (P-006; emitted by the OwnIR bridge) ---- "DI001": "captive dependency: a shorter-lived service is captured by a longer-lived one", "DI002": "singleton captures a scoped service (captive dependency)", @@ -182,6 +183,21 @@ class Severity(Enum): "Fix: this indicates malformed `functions[]` facts or a bridge bug — re-extract the " "facts, and report the message's inner error if it persists." ), + "OWN053": ( + "Advisory, not a leak verdict: a local was initialised by an un-awaited Task / ValueTask " + "invocation of an effectful operation (an owned result such as a reader or a transaction, " + "or a connection / transaction lifecycle call such as BeginTransactionAsync) and is never " + "awaited, returned, stored, passed or otherwise observed. The operation still runs — " + "BeginTransactionAsync changes the connection state inline, so every later command runs " + "inside a transaction nobody can commit and closing the connection rolls that work back; " + "the result is never released and a failure is lost. Standard async analyzers treat the " + "assignment itself as observing the task, so they stay silent here. It never fails a " + "build.\n" + "Fix: await the call and keep the result " + "(`await using var tx = await conn.BeginTransactionAsync()`), return or store it where " + "it is observed, or express fire-and-forget explicitly (`_ = ...`); " + "no automatic fix is offered — only the author knows which of the three was meant." + ), "DI002": ( "A singleton captures a scoped service: the scoped instance is pinned to the singleton " "for the whole app lifetime, defeating per-scope (e.g. per-request) semantics and often " diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 78ffd55f..e3728d55 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -343,6 +343,10 @@ def _route_resource(rkind: str) -> tuple[str, str]: # per-parameter effect. Like `_FLOW_OPS`, a closed enum the schema and the Rust # `ParamEffect` are bound to; an absent effect is inferred from the body. _PARAM_EFFECTS = frozenset({"consume", "borrow", "borrow_mut", "plain"}) +# OWN053 site families (spec/OwnIR.md §9): the closed set the strict door gates +# `orphaned_awaitables[].family` against; the schema's `orphanFamily` enum is +# pinned to it by tests/test_ownir.py exactly as `paramEffect` is to the set above. +_ORPHAN_FAMILIES = frozenset({"A_owned_result", "B_protocol_lifecycle"}) # --- P-004 region escape (the `capture` resource kind) ---------------------- # A `capture` is a tokenless strong subscription routed NOT through the @@ -1024,6 +1028,45 @@ def load(path: str) -> dict[str, Any]: parse_method(fraw) except ProtocolFactsError as e: raise OwnIRError(str(e)) from e + # OWN053 sites (spec/OwnIR.md §9; P-OWN053-DOOR). Validated LAST because it + # was the last section to join the door: the list shipped UNBOUND with the + # promotion (read by both engines through the tolerant coercions only) and + # is bound here because a default-on diagnostic is minted from every entry, + # so a malformed entry must fail loud at the door rather than render as an + # OWN053 built from the stringification of garbage. The two name slots + # first (identity — the finding's event and handler), then the anchor + # (`file`, `line`, `column` under the same §4.2 rules as every other + # coordinate), then the typed optionals; `family` is a closed vocabulary. + orphans = result.get("orphaned_awaitables", []) + if not isinstance(orphans, list) or not all(isinstance(o, dict) for o in orphans): + raise OwnIRError("OwnIR 'orphaned_awaitables' must be a JSON array of objects") + for o in orphans: + for slot in ("local", "callee"): + sv = o.get(slot) + if not isinstance(sv, str) or not sv: + raise OwnIRError( + f"orphaned awaitable '{slot}' must be a non-empty string, got {sv!r}") + fv = o.get("file") + if not isinstance(fv, str): + raise OwnIRError(f"orphaned awaitable 'file' must be a string, got {fv!r}") + oln = o.get("line") + if not isinstance(oln, int) or isinstance(oln, bool): + raise OwnIRError(f"orphaned awaitable 'line' must be an integer, got {oln!r}") + _check_line_domain(oln, "orphaned awaitable") + _check_column(o.get("column"), "orphaned awaitable") + mv = o.get("method") + if mv is not None and not isinstance(mv, str): + raise OwnIRError( + f"orphaned awaitable 'method' must be a string or null, got {mv!r}") + fam = o.get("family") + if fam is not None and (not isinstance(fam, str) or fam not in _ORPHAN_FAMILIES): + raise OwnIRError( + f"orphaned awaitable 'family' must be one of {sorted(_ORPHAN_FAMILIES)}, " + f"got {fam!r}") + rtv = o.get("result_type") + if rtv is not None and not isinstance(rtv, str): + raise OwnIRError( + f"orphaned awaitable 'result_type' must be a string or null, got {rtv!r}") return result @@ -3173,6 +3216,12 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: # this side path so it bypasses the ERROR-only diagnostic mapping above. findings.extend(_unresolved_findings(facts)) + # OWN053 (ownership-semantics-lab H-29, promoted): every `orphaned_awaitables` + # entry — a local initialised by an un-awaited awaitable invocation of an effectful + # operation and never observed again — as an advisory, through the same side path + # as OWN050. The list is absent when the extractor saw no site. + findings.extend(_orphaned_awaitable_findings(facts)) + # OWN051 (d5 §5's advisory channel): each owned local handed to a # may/unknown-contract position was optimistically untracked at that call — # surface the honest "not checked past here" note minted during lowering. @@ -3588,6 +3637,40 @@ def _protocol_findings(facts: dict[str, Any]) -> list[Finding]: return out +def _orphaned_awaitable_findings(facts: dict[str, Any]) -> list[Finding]: + """OWN053 "orphaned awaitable" (promoted from ownership-semantics-lab H-29): surface + every `orphaned_awaitables` entry the extractor collected as an advisory: a local + initialised by an un-awaited Task / ValueTask invocation whose operation is + effectful (an owned result or a connection / transaction lifecycle call) and + that is never awaited, returned, stored, passed or otherwise observed. The + operation still runs (BeginTransactionAsync changes the connection state + inline), its result is never released and its failure is lost. Advisory: + never a build failure. Absent list = nothing.""" + out: list[Finding] = [] + items = facts.get("orphaned_awaitables", []) + if not isinstance(items, list): + return out + for it in items: + if not isinstance(it, dict): + continue + local = str(it.get("local", "?")) + callee = str(it.get("callee", "?")) + rt = it.get("result_type") + res = f"its result {rt} is never released" if rt else "there is no result to release" + out.append(Finding( + file=str(it.get("file", "?")), line=_as_line(it.get("line", 0)), + column=_as_col(it.get("column")), code="OWN053", + component=str(it.get("method", "?")), event=local, handler=callee, + message=(f"orphaned awaitable: '{local}' = {callee}(...) is obtained and lost -- " + f"never awaited, returned, stored or otherwise observed; the operation " + f"still runs ({res}), its failure is lost, and a transaction / connection " + f"lifecycle call leaves the connection in a state nobody can finish. Await " + f"it and keep the result, return or store it where it is observed, or " + f"express fire-and-forget explicitly"), + kind="orphaned awaitable", advisory=True)) + return out + + def _unresolved_findings(facts: dict[str, Any]) -> list[Finding]: """Surface every "unresolved-subscription" marker as an advisory OWN050 finding (P-014 Tier A): the extractor saw a `+=` that looks like an event diff --git a/rust/crates/own-bridge/src/verdict.rs b/rust/crates/own-bridge/src/verdict.rs index a315a086..c2a03d7c 100644 --- a/rust/crates/own-bridge/src/verdict.rs +++ b/rust/crates/own-bridge/src/verdict.rs @@ -889,6 +889,46 @@ fn unresolved_findings(root: &Obj) -> Vec { out } +/// `_orphaned_awaitable_findings` (OWN053, promoted from ownership-semantics-lab H-29): +/// every `orphaned_awaitables` entry as an advisory — a local initialised by an un-awaited +/// awaitable invocation of an effectful operation and never observed again. Absent list +/// (the extractor saw no site) = nothing. +fn orphaned_awaitable_findings(root: &Obj) -> Vec { + let mut out = Vec::new(); + let Some(Value::Array(items)) = root.get("orphaned_awaitables") else { + return out; + }; + for it in items.iter().filter_map(Value::as_object) { + let local = get_or(it, "local", "?"); + let callee = get_or(it, "callee", "?"); + // a JSON null (a non-generic Task / ValueTask) has no result to release, exactly as the Python side words it + let res = it.get("result_type").and_then(Value::as_str).map_or_else( + || "there is no result to release".to_string(), + |rt| format!("its result {rt} is never released"), + ); + let mut f = Finding::new( + get_or(it, "file", "?"), + as_line(it.get("line")), + "OWN053", + "orphaned awaitable", + ); + f.column = as_col(it.get("column")); + f.component = get_or(it, "method", "?"); + f.event.clone_from(&local); + f.handler.clone_from(&callee); + f.message = format!( + "orphaned awaitable: '{local}' = {callee}(...) is obtained and lost -- never awaited, \ + returned, stored or otherwise observed; the operation still runs ({res}), its failure \ + is lost, and a transaction / connection lifecycle call leaves the connection in a state \ + nobody can finish. Await it and keep the result, return or store it where it is \ + observed, or express fire-and-forget explicitly" + ); + f.advisory = true; + out.push(f); + } + out +} + fn transfer_note(a: &Own051) -> Finding { let mut f = Finding::new(a.file.clone(), a.line, "OWN051", "ownership transfer"); f.component.clone_from(&a.component); @@ -1172,6 +1212,7 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { findings.extend(effect_findings(root)); findings.extend(protocol_findings(root)); findings.extend(unresolved_findings(root)); + findings.extend(orphaned_awaitable_findings(root)); findings.extend(lowering.advisories.iter().map(transfer_note)); let module_name = root.get("module").map_or_else(|| "?".to_owned(), py_str); for reason in &lowering.mos_notes { diff --git a/rust/crates/own-cli/src/text.rs b/rust/crates/own-cli/src/text.rs index 1cc3433e..61914f21 100644 --- a/rust/crates/own-cli/src/text.rs +++ b/rust/crates/own-cli/src/text.rs @@ -53,8 +53,8 @@ pub(crate) const OWNLANG_DOCSTRING: &str = concat!( "the finding is still the core's verdict.\n", "`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\n", "OWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\n", - "OWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\n", - "per-code breakdown).\n", + "OWN052 \"summaries skipped\", OWN053 \"orphaned awaitable\"), `normal` (default), or\n", + "`verbose` (also print a per-code breakdown).\n", "\n", "Exit code is non-zero if any error-level diagnostic was produced.\n", ); @@ -103,7 +103,7 @@ pub(crate) const OWNIR_USAGE: &str = concat!( " --severity {error|warning} how the host shows a finding; it never\n", " changes the exit code\n", " --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n", - " (OWN050/051/052, OBL005); verbose adds\n", + " (OWN050/051/052/053, OBL005); verbose adds\n", " a per-code breakdown over every\n", " finding, suppressed ones included\n", "\n", diff --git a/rust/crates/own-diagnostics/src/diagnostic.rs b/rust/crates/own-diagnostics/src/diagnostic.rs index e5784bca..3167d975 100644 --- a/rust/crates/own-diagnostics/src/diagnostic.rs +++ b/rust/crates/own-diagnostics/src/diagnostic.rs @@ -371,6 +371,10 @@ pub static TITLES: &[(&str, &str)] = &[ "OWN052", "interprocedural summary inference failed -- method summaries skipped", ), + ( + "OWN053", + "orphaned awaitable -- effectful async operation assigned but never observed", + ), ]; #[cfg(test)] @@ -394,7 +398,7 @@ mod tests { fn titles_count_matches_python_reference() { // Locked to `len(ownlang.diagnostics.TITLES)` — a drift on either side is // a real vocabulary change and must be made on both, together. - assert_eq!(TITLES.len(), 47); + assert_eq!(TITLES.len(), 48); } #[test] diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs index 1e09360d..e88ec7cd 100644 --- a/rust/crates/own-ir/src/strict.rs +++ b/rust/crates/own-ir/src/strict.rs @@ -422,7 +422,8 @@ pub(crate) fn validate_document(obj: &Map, source: Option<&str>) effects(obj)?; functions(obj)?; protocols(obj)?; - protocol_functions(obj) + protocol_functions(obj)?; + orphaned_awaitables(obj) } /// The version gate, first: a vocabulary mismatch makes every later shape check @@ -698,6 +699,74 @@ fn protocol_functions(obj: &Map) -> Checked { Ok(()) } +/// The closed set of OWN053 site families (`spec/OwnIR.md` §9), pinned to +/// `ownlang/ownir.py::_ORPHAN_FAMILIES` through the schema's `orphanFamily` +/// enum exactly as `PARAM_EFFECTS` is through `paramEffect`. +const ORPHAN_FAMILIES: [&str; 2] = ["A_owned_result", "B_protocol_lifecycle"]; + +/// `orphaned_awaitables[]` — the OWN053 site list (`spec/OwnIR.md` §9), +/// validated LAST because it was the last section to join the door +/// (P-OWN053-DOOR): the list shipped unbound with the promotion, read by both +/// bridges through the tolerant coercions only, and is bound here because a +/// default-on advisory is minted from every entry — a malformed entry must be +/// refused at the door, never rendered as a finding built from the +/// stringification of garbage. The two name slots first (`identity` — the +/// finding's event and handler), then the anchor under the same §4.2 rules as +/// every other coordinate, then the typed optionals; `family` is a closed +/// vocabulary, treated exactly as a parameter's `effect`. +fn orphaned_awaitables(obj: &Map) -> Checked { + let items = objects( + obj, + "orphaned_awaitables", + "OwnIR 'orphaned_awaitables' must be a JSON array of objects", + )?; + for it in items { + name_slot(it, "local", "orphaned awaitable")?; + name_slot(it, "callee", "orphaned awaitable")?; + match it.get("file") { + Some(Value::String(_)) => {} + Some(other) => { + return Err(shape(format!( + "orphaned awaitable 'file' must be a string, got {other}" + ))) + } + None => { + return Err(shape( + "orphaned awaitable 'file' must be a string, got absent", + )) + } + } + match it.get("line") { + Some(v) if is_representable_int(v) => { + line_domain(v.as_i64().unwrap_or(0), "orphaned awaitable", "line")?; + } + Some(other) => { + return Err(shape(format!( + "orphaned awaitable 'line' must be an integer, got {other}" + ))) + } + None => { + return Err(shape( + "orphaned awaitable 'line' must be an integer, got absent", + )) + } + } + column(it.get("column"), "orphaned awaitable")?; + optional_string(it, "method", "orphaned awaitable")?; + match it.get("family") { + None | Some(Value::Null) => {} + Some(v) if v.as_str().is_some_and(|f| ORPHAN_FAMILIES.contains(&f)) => {} + Some(other) => { + return Err(vocabulary(format!( + "orphaned awaitable 'family' must be one of {ORPHAN_FAMILIES:?}, got {other}" + ))) + } + } + optional_string(it, "result_type", "orphaned awaitable")?; + } + Ok(()) +} + /// The nesting depth beyond which a raw [`Value`] is refused — the one /// normative depth number in this crate. /// diff --git a/spec/Bridge.md b/spec/Bridge.md index 021152eb..a5138302 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -70,7 +70,11 @@ array) → `effects[]` (`deps` strings, `io` bool, `line` int, `bindings` non-empty `name`, `line` int, `effect` ∈ `_PARAM_EFFECTS` when present) → `protocols[]` via the shared obligation parser (fail-loud; **duplicate protocol names rejected** — the name is the identity verdicts map back by) → -`protocol_functions[]` via the shared method parser. Every violation raises +`protocol_functions[]` via the shared method parser → `orphaned_awaitables[]` +(the OWN053 site list, [OwnIR.md §9](OwnIR.md); array of objects; each entry: +non-empty `local` and `callee`, `file` string, `line` int-not-bool in the §4.2 +domain, `column`, `method` / `result_type` string-or-null, `family` ∈ +`_ORPHAN_FAMILIES` when present). Every violation raises `OwnIRError` with an actionable message, never a bare traceback. **BR-D2 (the tolerant door).** `check_facts(facts)` (and `to_module`/`to_own`) @@ -271,7 +275,8 @@ OBL005 (dead rule) is advisory and anchorless. **BR-V1 (the pipeline).** `check_facts` = `to_module` → `check_module(mod)` → map **ERROR-severity core diagnostics only** (sub-error core diagnostics are not mapped) → append, in order: DI findings, effect findings, protocol -findings, OWN050 advisories, OWN051 notes (minted during lowering), OWN052 +findings, OWN050 advisories, OWN053 orphaned-awaitable notes (one per +`orphaned_awaitables[]` entry), OWN051 notes (minted during lowering), OWN052 notes (one per solve-failure reason; anchorless: `file="?"`, `line=0`) → dedup (BR-V7) → sort (BR-V8). @@ -335,7 +340,7 @@ divergence collapsing is OD-5.) **BR-V8 (ordering).** The final list is stably sorted by `(file, line, code)`; ties keep pre-sort insertion order (core → DI → effects -→ protocols → OWN050 → OWN051 → OWN052, each in its own construction order). +→ protocols → OWN050 → OWN053 → OWN051 → OWN052, each in its own construction order). **BR-V9 (rendering).** `render`/`render_github`/`render_msbuild`/`build_sarif` are pure functions of the finding list (plus the host severity choice): diff --git a/spec/BridgeBehaviorMatrix.md b/spec/BridgeBehaviorMatrix.md index dc8c171c..66e3b0ad 100644 --- a/spec/BridgeBehaviorMatrix.md +++ b/spec/BridgeBehaviorMatrix.md @@ -78,7 +78,7 @@ | Behavior | Source | Rule | Pinned by | Layer | |---|---|---|---|---| | DI graph finders' verdict sets + messages + anchor metadata (DI001/002/003/004/005 unit layer) | `ownlang/di.py` (not the bridge) | BR-B1, BR-P1 | L805–L1048 (18) | (core suite) | -| advisory codes OWN051/OWN052 registered in `TITLES` (spec↔code drift guard) | `diagnostics.TITLES` | INF-P2/P3 | L1937 | — | +| advisory codes OWN051/OWN052/OWN053 registered in `TITLES` (spec↔code drift guard) | `diagnostics.TITLES` | INF-P2/P3 | L1937 | — | | effects re-validation skip-not-coerce; protocol first-wins on tolerant door | `_effect_findings`, `_protocol_findings` | BR-D2, BR-P2/P3 | (pinned in `test_effects.py` / `test_obligations.py`) | L3 ✅ | | obligation protocols: the lattice, the leaf order, the exits, the loop's single emission, the evidence and the sort key | `ownlang/obligations.py` (not the bridge) | BR-B1, BR-P3 | `test_obligations.py` §1 + `tests/test_obligation_fact_parity.py` | (core suite) | | protocol verdict mapping: `(kind, definite)` → OBL001–004, the four line-free wordings, component/handler, the opened→barrier(→late-close) slice, the anchorless OBL005 | `_protocol_findings`, `_protocol_message` | BR-P3, BR-V4/V5/V6 | `test_obligations.py` §3 + the `verdict_protocol_*` Layer 3 cases | L3 ✅ | diff --git a/spec/Diagnostics.md b/spec/Diagnostics.md index 1748dcfa..badb2098 100644 --- a/spec/Diagnostics.md +++ b/spec/Diagnostics.md @@ -101,6 +101,35 @@ of `--severity`, excluded from the exit code, hidden at `--verbosity quiet`. - **OWN052** is module-level (no single site): the summary solve failed and the bridge degraded to intraprocedural-only checking ([Inference §F6](Inference.md)). +## Orphaned awaitables (H-29) + +Advisory only — a *lost-acquisition note*, never a verdict. Emitted by the OwnIR +bridge from the extractor's additive `orphaned_awaitables[]` list +([OwnIR.md §9](OwnIR.md)) when a local is initialised by an **un-awaited** +`Task` / `ValueTask` invocation of an effectful operation and is **never +referenced again** in its member — not awaited, returned, stored, passed or +otherwise observed. Effectful means: the unwrapped result is a real disposable +(a reader, a transaction, a response), or the member is a connection / +transaction lifecycle call (`BeginTransaction(Async)`, `Commit(Async)`, +`Rollback(Async)`, `Open(Async)`, `Close(Async)`, `DisposeAsync`, +`SaveChangesAsync`, `FlushAsync`). The operation still runs — `BeginTransactionAsync` +changes the connection state inline, so every later command runs inside a +transaction nobody can commit and closing the connection rolls that work back; +the result is never released and a failure is lost. Standard async analyzers +(CA2012, VSTHRD110, MA0134, CS4014) treat the assignment itself as observing +the task and stay silent; IDE0059 goes silent for this shape inside `try` / +`finally`. Rendered as a `warning` regardless of `--severity`, excluded from the +exit code, hidden at `--verbosity quiet`. Deliberately narrow: `_ = X()` discards +and bare `X();` statements are other rules' territory, non-effectful awaitables +(`Task.Delay`, pure computations) are out of scope, and new lifecycle names +enter the list only with a runtime witness. No automatic fix is offered — only +the author knows whether the call should be awaited, kept, or explicitly +fire-and-forget. + +| Code | Title | +|------|-------| +| OWN053 | orphaned awaitable — effectful async operation assigned but never observed | + ## Rendering The CLI renders rustc-style: `file:line:col`, the source line, and a caret under diff --git a/spec/OwnIR.md b/spec/OwnIR.md index 3dcc538b..035d288f 100644 --- a/spec/OwnIR.md +++ b/spec/OwnIR.md @@ -2,7 +2,7 @@ > **Status: normative, descriptive.** This document specifies the OwnIR fact > contract *as it is today*, derived from the working bridge -> (`ownlang/ownir.py`) and pinned by tests (see [§10 Conformance](#10-conformance)). +> (`ownlang/ownir.py`) and pinned by tests (see [§11 Conformance](#11-conformance)). > Forward-looking ideas live in [`docs/proposals/`](../docs/proposals/), never > here. @@ -235,6 +235,11 @@ so a domain the door does not check is not stated by the schema either the type); the producer refuses to write a record whose line or column is below 1. The instrument step that registers the sidecar at the doors binds both coordinates to the domain above, at which point this paragraph goes. +(The orphaned-awaitable site list of §9 was a second such path for exactly one +commit — it shipped read through the tolerant coercions only — and was bound +at both doors by P-OWN053-DOOR once a default-on advisory was minted from it: +a list a user-facing diagnostic is built from is not inert, and the sidecar +exception above is the only one left.) **Flow bodies and protocol event trees nest at most 32 levels.** @@ -514,7 +519,44 @@ the barrier)*. Messages are deliberately line-free so baseline ratchets and FP-judge overlays that fingerprint on (path, rule, message) survive unrelated edits. -## 9. Rules +## 9. Orphaned awaitables (`orphaned_awaitables[]`) + +An optional, additive top-level array feeding the **OWN053** orphaned-awaitable +advisory ([Diagnostics.md](Diagnostics.md)). The C# extractor emits one entry per +local that is initialised by an un-awaited `Task` / `ValueTask` invocation of an +effectful operation and is never referenced again in its member; a document +whose extractor saw no such site carries no array at all, so it stays +byte-identical to the pre-OWN053 shape (`ownir_version` unchanged — the field is +additive, like `fix_candidates_version`): + +```json +"orphaned_awaitables": [ + {"file": "Q.cs", "line": 119, "column": 17, "method": "Q.ScheduleRetryAsync", + "local": "tx", "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", "result_type": "Npgsql.NpgsqlTransaction"} +] +``` + +Each entry carries `file`, `line`, `column` (the declarator), `method` (the +enclosing method's canonical name), `local`, `callee` (`Type.Member/arity`), +`family` (`A_owned_result` — the unwrapped result is a real disposable; +`B_protocol_lifecycle` — a connection / transaction lifecycle member name) and +`result_type` (the unwrapped result type, or `null` for a non-generic awaitable). +The bridge turns every entry into one advisory finding; it never lowers them and +never makes a verdict out of them. The decision of which sites qualify is the +frontend's (it owns the syntax and the reference count); the engines only render. +Both strict doors validate the list, last in BR-D1 order (after +`protocol_functions`; P-OWN053-DOOR): it must be an array of objects; each entry +carries non-empty `local` and `callee` (name slots), a string `file`, a `line` +in the §4.2 domain, an optional `column` under the same rules as every other +column, `method` and `result_type` as string-or-null, and `family` from the +closed set `A_owned_result` / `B_protocol_lifecycle` when present. The tolerant +door (`check_facts` on an un-validated document) keeps degrading: an +out-of-domain line reads as 0, an out-of-domain column as absent, and an entry +that is not an object is skipped — two entry points, two contracts, as for +every other coordinate. + +## 10. Rules - **IR1.** `ownir_version` must equal the core's `OWNIR_VERSION` (or be absent); otherwise `load()` raises `OwnIRError`. @@ -528,7 +570,7 @@ edits. never a silently dropped verdict. - **IR6.** A frontend emits facts only; all verdicts come from the core. -## 10. Conformance +## 11. Conformance Pinned by [`tests/test_ownir.py`](../tests/test_ownir.py) (the bridge suite, `python tests/test_ownir.py`), not `test_spec.py` (OwnIR is a bridge contract, diff --git a/spec/ownir.schema.json b/spec/ownir.schema.json index 34896ccd..65df9d57 100644 --- a/spec/ownir.schema.json +++ b/spec/ownir.schema.json @@ -43,6 +43,11 @@ "description": "Per-method ordered obligation-event trees the protocols are checked against (spec/OwnIR.md §8) — the fact side of the pair.", "type": "array", "items": { "$ref": "#/$defs/protocolFunction" } + }, + "orphaned_awaitables": { + "description": "Optional, additive: the orphaned-awaitable sites feeding the OWN053 advisory (spec/OwnIR.md §9); absent when the extractor saw no site. Validated LAST by both strict doors (P-OWN053-DOOR); the tolerant coercions remain the contract for check_facts on an un-validated document.", + "type": "array", + "items": { "$ref": "#/$defs/orphanedAwaitable" } } }, "$defs": { @@ -762,6 +767,27 @@ "description": "`!p` (truth only). A negated null test is not a predicate here: it degrades to absence." } } + }, + "orphanedAwaitable": { + "description": "One OWN053 orphaned-awaitable site (spec/OwnIR.md §9): a local initialised by an un-awaited Task / ValueTask invocation of an effectful operation and never referenced again in its member. `line` and `column` are the declarator's coordinates, bound to the shared §4.2 domain because both strict doors validate every entry (P-OWN053-DOOR; the list shipped unbound with the promotion and was bound once a default-on advisory was minted from it). `local` and `callee` are name slots (the finding's event and handler), `family` a closed vocabulary pinned to ownlang/ownir.py::_ORPHAN_FAMILIES, `method` and `result_type` string-or-null.", + "type": "object", + "required": ["file", "line", "local", "callee"], + "additionalProperties": false, + "properties": { + "file": { "type": "string" }, + "line": { "$ref": "#/$defs/sourceLine" }, + "column": { "$ref": "#/$defs/sourceColumn" }, + "method": { "type": ["string", "null"] }, + "local": { "type": "string", "minLength": 1 }, + "callee": { "type": "string", "minLength": 1 }, + "family": { "$ref": "#/$defs/orphanFamily" }, + "result_type": { "type": ["string", "null"] } } + }, + "orphanFamily": { + "description": "The OWN053 site family (spec/OwnIR.md §9): A_owned_result — the unwrapped awaitable result is a real disposable; B_protocol_lifecycle — the member is a connection / transaction lifecycle call. Pinned to the set in ownlang/ownir.py::load() (_ORPHAN_FAMILIES).", + "type": "string", + "enum": ["A_owned_result", "B_protocol_lifecycle"] + } } } diff --git a/tests/coordinate_census.py b/tests/coordinate_census.py index c0bf9cb6..6e45ebb0 100644 --- a/tests/coordinate_census.py +++ b/tests/coordinate_census.py @@ -91,6 +91,10 @@ (f"functions[].{NESTED}.column", "column"), ("protocol_functions[].events[].line", "line"), (f"protocol_functions[].events[].{NESTED}.line", "line"), + # The OWN053 site anchor (spec/OwnIR.md §9), a door slot since + # P-OWN053-DOOR bound the list at both strict doors. + ("orphaned_awaitables[].line", "line"), + ("orphaned_awaitables[].column", "column"), ) diff --git a/tests/fixtures/cli_ownir/manifest.json b/tests/fixtures/cli_ownir/manifest.json index 9ac47de0..ca8b71df 100644 --- a/tests/fixtures/cli_ownir/manifest.json +++ b/tests/fixtures/cli_ownir/manifest.json @@ -3,7 +3,7 @@ "cli_ownir_version": 1, "own_cli_version": "0.1.0", "shell_usage": "own-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", - "ownir_usage": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "ownir_usage": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052/053, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", "unknown_command_line": "own-cli: unknown command {name!r}\n", "os_error_placeholder": "", "declared_boundaries": { diff --git a/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json b/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json index 5437c150..f7fce7da 100644 --- a/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json +++ b/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json @@ -8,7 +8,7 @@ "env": {}, "expected": { "exit": 0, - "stdout": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "stdout": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052/053, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", "stderr": "", "os_error_tail": null } diff --git a/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json b/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json index 87e7ce34..6cec693a 100644 --- a/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json +++ b/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json @@ -9,7 +9,7 @@ "env": {}, "expected": { "exit": 2, - "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\", OWN053 \"orphaned awaitable\"), `normal` (default), or\n`verbose` (also print a per-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", "stderr": "", "os_error_tail": null } diff --git a/tests/fixtures/cli_ownir/usage-no-positional.case.json b/tests/fixtures/cli_ownir/usage-no-positional.case.json index 963beddf..396f7e71 100644 --- a/tests/fixtures/cli_ownir/usage-no-positional.case.json +++ b/tests/fixtures/cli_ownir/usage-no-positional.case.json @@ -7,7 +7,7 @@ "env": {}, "expected": { "exit": 2, - "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\", OWN053 \"orphaned awaitable\"), `normal` (default), or\n`verbose` (also print a per-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", "stderr": "", "os_error_tail": null } diff --git a/tests/fixtures/cli_ownir/usage-two-positionals.case.json b/tests/fixtures/cli_ownir/usage-two-positionals.case.json index 933dbf59..76ca31c8 100644 --- a/tests/fixtures/cli_ownir/usage-two-positionals.case.json +++ b/tests/fixtures/cli_ownir/usage-two-positionals.case.json @@ -9,7 +9,7 @@ "env": {}, "expected": { "exit": 2, - "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\", OWN053 \"orphaned awaitable\"), `normal` (default), or\n`verbose` (also print a per-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", "stderr": "", "os_error_tail": null } diff --git a/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json b/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json index 6eadc5c6..1ce46f8a 100644 --- a/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json +++ b/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json @@ -9,7 +9,7 @@ "env": {}, "expected": { "exit": 2, - "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\", OWN053 \"orphaned awaitable\"), `normal` (default), or\n`verbose` (also print a per-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", "stderr": "", "os_error_tail": null } diff --git a/tests/fixtures/diag_ledger.json b/tests/fixtures/diag_ledger.json index 83afc5e3..6f6cf037 100644 --- a/tests/fixtures/diag_ledger.json +++ b/tests/fixtures/diag_ledger.json @@ -2,12 +2,12 @@ "comment": "GENERATED by tests/test_diag_ledger_fixtures.py --write; do not edit. Python (ownlang) is authoritative. The COMPLETE diagnostic-family ledger (P-022 step 5a, issue #255, PR 3 of 3): every TITLES code has a case, so a new family cannot ship without a fixture. `analyzer_corpus` records whether the real .own sweep produces that code today -- rendering coverage is not the same claim as analyzer coverage, and this file does not conflate them.", "schema_version": 1, "totals": { - "codes": 47, + "codes": 48, "by_family": { "DI": 5, "EFF": 1, "OBL": 5, - "OWN": 36 + "OWN": 37 }, "analyzer_corpus": 13 }, @@ -1078,6 +1078,23 @@ ] }, "rendered": "src/Ledger.cs:882: error: [OWN052] 'own052_subject' -- interprocedural summary inference failed -- method summaries skipped\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN053", + "family": "OWN", + "analyzer_corpus": false, + "title": "orphaned awaitable -- effectful async operation assigned but never observed", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN053", + "message": "'own053_subject' -- orphaned awaitable -- effectful async operation assigned but never observed", + "line": 844, + "severity": "error", + "subject": null, + "resource_kind": "subscription token", + "evidence": [] + }, + "rendered": "src/Ledger.cs:844: error: [OWN053] 'own053_subject' -- orphaned awaitable -- effectful async operation assigned but never observed [resource: subscription token]" } ] } diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json index 7d87929c..3b687c83 100644 --- a/tests/fixtures/ownir_validation.json +++ b/tests/fixtures/ownir_validation.json @@ -11,17 +11,17 @@ "well_formedness": "right types, legal vocabulary, and the record still cannot mean anything" }, "totals": { - "cases": 294, - "accepted": 60, - "rejected": 234, + "cases": 344, + "accepted": 69, + "rejected": 275, "by_category": { - "accepted": 60, - "identity": 22, + "accepted": 69, + "identity": 33, "json": 2, - "location": 50, - "shape": 132, + "location": 57, + "shape": 151, "version": 7, - "vocabulary": 19, + "vocabulary": 23, "well_formedness": 2 } }, @@ -7887,6 +7887,1010 @@ "verdict": "reject", "category": "location", "message": "function body op 'acquire' 'line' must be a source line in [0, 2147483647], got -1 (spec/OwnIR.md §4.2)" + }, + { + "name": "accept-orphaned-empty-list", + "why": "the list present and empty: nothing to check", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-orphaned-minimal", + "why": "the four required fields alone (local, callee, file, line) — the optionals are optional", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-orphaned-full-family-a", + "why": "a complete family-A entry as the extractor writes it: column, method, family and the unwrapped result type", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 17, + "method": "Q.ScheduleRetryAsync", + "family": "A_owned_result", + "result_type": "Npgsql.NpgsqlTransaction" + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-orphaned-family-b-null-result", + "why": "a family-B entry: a non-generic awaitable has no result type, so `result_type` is an explicit null, and `method` / `column` may be null too", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "c", + "callee": "Npgsql.NpgsqlTransaction.CommitAsync/1", + "file": "Q.cs", + "line": 119, + "family": "B_protocol_lifecycle", + "result_type": null, + "method": null, + "column": null + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-orphaned-two-entries", + "why": "entries are independent records; two valid ones are two sites", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + }, + { + "local": "r", + "callee": "Npgsql.NpgsqlCommand.ExecuteReaderAsync/1", + "file": "Q.cs", + "line": 120 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "orphaned-not-array", + "why": "the section must be an array of objects", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": { + "local": "tx" + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'orphaned_awaitables' must be a JSON array of objects" + }, + { + "name": "orphaned-null-section", + "why": "…and a PRESENT null is not absent", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'orphaned_awaitables' must be a JSON array of objects" + }, + { + "name": "orphaned-entry-not-object", + "why": "an entry that is a scalar", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + 7 + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'orphaned_awaitables' must be a JSON array of objects" + }, + { + "name": "orphaned-entry-null", + "why": "…and null is not a record", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + null + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'orphaned_awaitables' must be a JSON array of objects" + }, + { + "name": "orphaned-entry-string", + "why": "…nor is a bare string, however much it looks like a local", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + "tx" + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'orphaned_awaitables' must be a JSON array of objects" + }, + { + "name": "orphaned-local-empty", + "why": "`local` is the finding's event slot — a name slot, so empty is `identity`", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got ''" + }, + { + "name": "orphaned-local-absent", + "why": "…and it is required", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got None" + }, + { + "name": "orphaned-local-not-string", + "why": "…and a list is not a name — the exact shape the unbound list rendered as a finding", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": [ + "what", + "is", + "this" + ], + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got ['what', 'is', 'this']" + }, + { + "name": "orphaned-local-null", + "why": "…and null is not a name either", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": null, + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got None" + }, + { + "name": "orphaned-callee-empty", + "why": "`callee` is the finding's handler slot (Type.Member/arity) — a name slot", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'callee' must be a non-empty string, got ''" + }, + { + "name": "orphaned-callee-absent", + "why": "…and it is required", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'callee' must be a non-empty string, got None" + }, + { + "name": "orphaned-callee-not-string", + "why": "…and an object is not a name", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": { + "oops": 1 + }, + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'callee' must be a non-empty string, got {'oops': 1}" + }, + { + "name": "orphaned-file-absent", + "why": "`file` is required (the anchor's file)", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'file' must be a string, got None" + }, + { + "name": "orphaned-file-not-string", + "why": "…and it must be a string", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": 7, + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'file' must be a string, got 7" + }, + { + "name": "orphaned-file-null", + "why": "…a present null included", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": null, + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'file' must be a string, got None" + }, + { + "name": "orphaned-line-absent", + "why": "`line` is required — unlike the defaulted lines elsewhere, an entry with no line is not a site", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must be an integer, got None" + }, + { + "name": "orphaned-line-string", + "why": "a string line has no integer form", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": "119" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must be an integer, got '119'" + }, + { + "name": "orphaned-line-bool", + "why": "…and the bool-is-int trap", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": true + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must be an integer, got True" + }, + { + "name": "orphaned-line-null", + "why": "…and a present null", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must be an integer, got None" + }, + { + "name": "orphaned-line-float", + "why": "…and a float, which is not a coordinate", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 1.5 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must be an integer, got 1.5" + }, + { + "name": "orphaned-line-below-i64", + "why": "a line with no signed-64 form: `shape` by mechanism, on the other axis from the domain controls below", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": -9223372036854775809 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'line' must fit a signed 64-bit integer, got -9223372036854775809 (spec/OwnIR.md §4.2)" + }, + { + "name": "accept-orphaned-column-one", + "why": "the 1-based boundary on the site column", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 1 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "orphaned-column-zero", + "why": "the same column contract as every other column: 0 is a producer bug, not 'unknown'", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 0 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "orphaned-column-negative", + "why": "…negative likewise", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": -1 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'column' must be a 1-based integer or absent, got -1" + }, + { + "name": "orphaned-column-bool", + "why": "…and the bool-is-int trap on the column", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": true + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'column' must be a 1-based integer or absent, got True" + }, + { + "name": "orphaned-column-string", + "why": "…and a string, which has no integer form to be 1-based about", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": "17" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'column' must be a 1-based integer or absent, got '17'" + }, + { + "name": "orphaned-method-not-string", + "why": "`method` is a string or null", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "method": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'method' must be a string or null, got 7" + }, + { + "name": "orphaned-family-unknown", + "why": "`family` is a closed set: a third family does not exist until a witness earns it", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "family": "C_whatever" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "orphaned awaitable 'family' must be one of ['A_owned_result', 'B_protocol_lifecycle'], got 'C_whatever'" + }, + { + "name": "orphaned-family-not-string", + "why": "…and a non-string is outside the set the same way a non-string parameter effect is", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "family": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "orphaned awaitable 'family' must be one of ['A_owned_result', 'B_protocol_lifecycle'], got 7" + }, + { + "name": "orphaned-family-empty", + "why": "…and so is the empty string", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "family": "" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "orphaned awaitable 'family' must be one of ['A_owned_result', 'B_protocol_lifecycle'], got ''" + }, + { + "name": "orphaned-result-type-not-string", + "why": "`result_type` is a string or null — the number the unbound list would have rendered as a type name", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "result_type": 12345 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'result_type' must be a string or null, got 12345" + }, + { + "name": "orphaned-order-local-before-callee", + "why": "both name slots broken: `local` is checked first — observable only through the message, so the control pins the identity category and the pair is kept for a reader", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "", + "callee": 7, + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got ''" + }, + { + "name": "orphaned-order-callee-before-file", + "why": "identity precedes the anchor: a broken callee outranks a missing file", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'callee' must be a non-empty string, got ''" + }, + { + "name": "orphaned-order-file-before-line", + "why": "…and the file precedes the line", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": 7, + "line": -1 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "orphaned awaitable 'file' must be a string, got 7" + }, + { + "name": "orphaned-order-line-before-column", + "why": "…the line's domain precedes the column's type (§4.1 order)", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": -1, + "column": true + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'line' must be a source line in [0, 2147483647], got -1 (spec/OwnIR.md §4.2)" + }, + { + "name": "orphaned-order-column-before-family", + "why": "…and the column precedes the family vocabulary", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 0, + "family": "C_whatever" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "orphaned-order-family-before-result-type", + "why": "…and the family precedes the result type", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "family": "C_whatever", + "result_type": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "orphaned awaitable 'family' must be one of ['A_owned_result', 'B_protocol_lifecycle'], got 'C_whatever'" + }, + { + "name": "order-protocol-functions-before-orphaned", + "why": "across sections: `protocol_functions` is validated before `orphaned_awaitables`, so a protocol function without a name (identity) outranks a non-array site list (shape)", + "section": "order", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "file": "a.cs" + } + ], + "orphaned_awaitables": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function: 'name' must be a non-empty string, got None" + }, + { + "name": "order-orphaned-is-last", + "why": "…and nothing is validated after it: a document whose only defect is in the site list reports that defect", + "section": "order", + "document": { + "ownir_version": 0, + "components": [], + "services": [], + "effects": [], + "functions": [], + "protocols": [], + "protocol_functions": [], + "orphaned_awaitables": [ + { + "local": "", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "orphaned awaitable 'local' must be a non-empty string, got ''" + }, + { + "name": "accept-orphaned-line-zero", + "why": "`0` is the bottom of the domain and means 'unknown / file-level'", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 0 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-orphaned-line-at-int32-max", + "why": "…and 2147483647 is the top, accepted exactly", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 2147483647 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "orphaned-line-negative", + "why": "one below the bottom: a representable coordinate outside its domain, so `location` rather than `shape`", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": -1 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'line' must be a source line in [0, 2147483647], got -1 (spec/OwnIR.md §4.2)" + }, + { + "name": "orphaned-line-above-int32", + "why": "…and one above the top — the value the unbound list degraded to 0 and the strict door now refuses", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 2147483648 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'line' must be a source line in [0, 2147483647], got 2147483648 (spec/OwnIR.md §4.2)" + }, + { + "name": "accept-orphaned-column-at-int32-max", + "why": "the top of the column domain, accepted exactly", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 2147483647 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "orphaned-column-above-int32", + "why": "…and one past it: representable, positive, and outside the domain — `location`, the same axis the 1-based rule is on", + "section": "orphaned_awaitables", + "document": { + "ownir_version": 0, + "orphaned_awaitables": [ + { + "local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", + "line": 119, + "column": 2147483648 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "orphaned awaitable 'column' must be a source column in [1, 2147483647], got 2147483648 (spec/OwnIR.md §4.2)" } ] } diff --git a/tests/fixtures/repro/digests.json b/tests/fixtures/repro/digests.json index 5f13c373..73d4f446 100644 --- a/tests/fixtures/repro/digests.json +++ b/tests/fixtures/repro/digests.json @@ -507,6 +507,12 @@ "digest": "ff3d7627f6143425985ad86aedb3f6fb962118c72de6e1b047ba8bf0026238ec", "bytes": 1262 }, + { + "case": "verdict_own053_orphaned_awaitable", + "corpus": "verdicts", + "digest": "749bd19c7f9d55daf062a251fa21bb64de3abc5614809d562856a8ee457abc00", + "bytes": 1980 + }, { "case": "verdict_pool_view_anchor", "corpus": "verdicts", diff --git a/tests/fixtures/verdicts/manifest.json b/tests/fixtures/verdicts/manifest.json index 4b0bcb37..a353cd9a 100644 --- a/tests/fixtures/verdicts/manifest.json +++ b/tests/fixtures/verdicts/manifest.json @@ -288,6 +288,14 @@ "BR-V4", "BR-V5" ] + }, + { + "name": "verdict_own053_orphaned_awaitable", + "rules": [ + "BR-V1", + "BR-V4", + "BR-V8" + ] } ] } diff --git a/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.facts.json b/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.facts.json new file mode 100644 index 00000000..d177b023 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.facts.json @@ -0,0 +1,46 @@ +{ + "ownir_version": 0, + "module": "Orphans", + "_doc": [ + "BR-V1 / BR-V4 / BR-V8: the OWN053 orphaned-awaitable advisory, promoted from", + "ownership-semantics-lab H-29. Every `orphaned_awaitables` entry becomes ONE", + "advisory finding through the OWN050 side path — never a verdict, never the", + "exit code — with the entry's file/line/column as the anchor, its method as the", + "component, the local as the event and the callee as the handler.", + "Both BR-V4 wording tails are reached: a generic awaitable names the result", + "that is never released (`result_type`), a non-generic one (`result_type`", + "null) says there is no result to release. The entries are given out of", + "source order and beside an OWN050 in the same file, so the BR-V8 sort —", + "(file, line, column, code) — is what the golden shows, not insertion order.", + "Neither door validates the list (spec/OwnIR.md §4.2): the two coordinates", + "reach the finding through the tolerant coercions only; the third entry's", + "out-of-domain line degrades to 0 (file-level, never clamped) and its", + "out-of-domain column to absent, which is why it sorts FIRST." + ], + "components": [ + { + "name": "QueueView", + "file": "Queue.cs", + "subscriptions": [ + { + "event": "grid.CellValueChanged", + "handler": "OnCellChanged", + "line": 119, + "resource": "unresolved-subscription" + } + ] + } + ], + "functions": [], + "orphaned_awaitables": [ + {"file": "Queue.cs", "line": 119, "column": 17, "method": "Queue.PostgresqlQueueSender.ScheduleRetryAsync", + "local": "tx", "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", "result_type": "Npgsql.NpgsqlTransaction"}, + {"file": "Queue.cs", "line": 42, "column": 13, "method": "Queue.PostgresqlQueueSender.FlushAsync", + "local": "commit", "callee": "Npgsql.NpgsqlTransaction.CommitAsync/1", + "family": "B_protocol_lifecycle", "result_type": null}, + {"file": "Queue.cs", "line": 2147483648, "column": 0, "method": "Queue.PostgresqlQueueSender.OpenAsync", + "local": "open", "callee": "Npgsql.NpgsqlConnection.OpenAsync/1", + "family": "B_protocol_lifecycle", "result_type": null} + ] +} diff --git a/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.verdicts.json b/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.verdicts.json new file mode 100644 index 00000000..fa45fde2 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own053_orphaned_awaitable.verdicts.json @@ -0,0 +1,69 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Queue.cs", + "line": 0, + "code": "OWN053", + "component": "Queue.PostgresqlQueueSender.OpenAsync", + "event": "open", + "handler": "Npgsql.NpgsqlConnection.OpenAsync/1", + "message": "orphaned awaitable: 'open' = Npgsql.NpgsqlConnection.OpenAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly", + "kind": "orphaned awaitable", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Queue.cs", + "line": 42, + "code": "OWN053", + "component": "Queue.PostgresqlQueueSender.FlushAsync", + "event": "commit", + "handler": "Npgsql.NpgsqlTransaction.CommitAsync/1", + "message": "orphaned awaitable: 'commit' = Npgsql.NpgsqlTransaction.CommitAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (there is no result to release), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly", + "kind": "orphaned awaitable", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 13 + }, + { + "file": "Queue.cs", + "line": 119, + "code": "OWN050", + "component": "QueueView", + "event": "grid.CellValueChanged", + "handler": "OnCellChanged", + "message": "cannot verify 'grid.CellValueChanged' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped", + "kind": "unresolved reference", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Queue.cs", + "line": 119, + "code": "OWN053", + "component": "Queue.PostgresqlQueueSender.ScheduleRetryAsync", + "event": "tx", + "handler": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "message": "orphaned awaitable: 'tx' = Npgsql.NpgsqlConnection.BeginTransactionAsync/1(...) is obtained and lost -- never awaited, returned, stored or otherwise observed; the operation still runs (its result Npgsql.NpgsqlTransaction is never released), its failure is lost, and a transaction / connection lifecycle call leaves the connection in a state nobody can finish. Await it and keep the result, return or store it where it is observed, or express fire-and-forget explicitly", + "kind": "orphaned awaitable", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 17 + } + ] +} diff --git a/tests/test_cli_ownir_fixtures.py b/tests/test_cli_ownir_fixtures.py index c866827f..4825c2c4 100644 --- a/tests/test_cli_ownir_fixtures.py +++ b/tests/test_cli_ownir_fixtures.py @@ -156,7 +156,7 @@ class NonDeterministic(RuntimeError): --severity {error|warning} how the host shows a finding; it never changes the exit code --verbosity {quiet|normal|verbose} quiet hides the advisory notes - (OWN050/051/052, OBL005); verbose adds + (OWN050/051/052/053, OBL005); verbose adds a per-code breakdown over every finding, suppressed ones included diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 75a3d589..2a2b4cd9 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -31,6 +31,7 @@ from ownlang.ownir import ( _FLOW_OPS, _KNOWN_RESOURCE_KINDS, + _ORPHAN_FAMILIES, _PARAM_EFFECTS, OWNIR_VERSION, Finding, @@ -529,6 +530,20 @@ def _sub(source: str | None) -> list[Finding]: if _se != set(_PARAM_EFFECTS): fails.append(f"schema paramEffect enum {sorted(_se)} != code " f"_PARAM_EFFECTS {sorted(_PARAM_EFFECTS)}") + # 3c) orphanFamily enum == _ORPHAN_FAMILIES (the load() OWN053-family authority, + # P-OWN053-DOOR) — and the entry's `family` must reach it by $ref, so the + # closed set lives in exactly one place of the schema. + checks += 1 + _sf = set(_defs.get("orphanFamily", {}).get("enum", [])) + if _sf != set(_ORPHAN_FAMILIES): + fails.append(f"schema orphanFamily enum {sorted(_sf)} != code " + f"_ORPHAN_FAMILIES {sorted(_ORPHAN_FAMILIES)}") + checks += 1 + _fam_ref = (_defs.get("orphanedAwaitable", {}).get("properties", {}) + .get("family", {}).get("$ref")) + if _fam_ref != "#/$defs/orphanFamily": + fails.append("schema orphanedAwaitable.family must $ref orphanFamily, " + f"got {_fam_ref!r}") # 4) flowOp discriminator consts. `_FLOW_OPS` is the lowerer's authoritative # op set (the _lower_flow `else` rejects anything outside it as vocabulary # skew). Bind the schema to it BOTH ways: (a) the schema's oneOf consts must @@ -2000,9 +2015,63 @@ def _boom2(_sk): # type: ignore[no-untyped-def] # OWN052) must be registered in the catalogue — a spec that references an # unregistered code, or a dropped code, is drift the build must catch. checks += 1 - missing = [c for c in ("OWN051", "OWN052") if c not in TITLES] + missing = [c for c in ("OWN051", "OWN052", "OWN053") if c not in TITLES] if missing: - fails.append(f"Inference.md names unregistered code(s): {missing}") + fails.append(f"Inference.md / Diagnostics.md name unregistered code(s): {missing}") + # OWN053 "orphaned awaitable" (ownership-semantics-lab H-29, promoted): the + # extractor's additive top-level `orphaned_awaitables` list is surfaced as ONE + # advisory per entry through the OWN050 side path — never a verdict, never the + # exit code — and a document without the list (the extractor saw no site, or a + # pre-OWN053 producer) carries none. The message tells the author what to do + # (await / return / store and observe, or express fire-and-forget) and offers no + # automatic fix. + checks += 1 + orphan_facts = {"module": "M", "functions": [], "orphaned_awaitables": [ + {"file": "Q.cs", "line": 119, "column": 17, "method": "Q.ScheduleRetryAsync", + "local": "tx", "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "family": "A_owned_result", "result_type": "Npgsql.NpgsqlTransaction"}]} + o53 = check_facts(orphan_facts) + got53 = [(x.file, x.line, x.column, x.code, x.advisory, x.component, x.event, + x.handler) for x in o53] + if got53 != [("Q.cs", 119, 17, "OWN053", True, "Q.ScheduleRetryAsync", "tx", + "Npgsql.NpgsqlConnection.BeginTransactionAsync/1")]: + fails.append(f"OWN053: one advisory per orphaned_awaitables entry, got {got53}") + elif ("obtained and lost" not in o53[0].message + or "its result Npgsql.NpgsqlTransaction is never released" not in o53[0].message + or "express fire-and-forget explicitly" not in o53[0].message): + fails.append(f"OWN053 message drifted: {o53[0].message!r}") + checks += 1 + bare = check_facts({"module": "M", "functions": []}) + if any(x.code == "OWN053" for x in bare): + fails.append("OWN053 must not fire without an orphaned_awaitables list") + checks += 1 + nores = check_facts({"module": "M", "functions": [], "orphaned_awaitables": [ + {"file": "Q.cs", "line": 5, "method": "Q.M", "local": "c", + "callee": "Npgsql.NpgsqlTransaction.CommitAsync/1", "family": "B_protocol_lifecycle", + "result_type": None}]}) + if len(nores) != 1 or "there is no result to release" not in nores[0].message: + fails.append("OWN053 on a non-generic awaitable must say there is no result: " + f"{[x.message for x in nores]}") + # `check_facts` on a dict is the TOLERANT entry: it never runs the strict door, so + # the coercions are its only guard on these coordinates — an out-of-domain line + # degrades to 0 (file-level, never clamped to a real line) and an out-of-domain + # column to absent; an entry that is not an object is skipped, and a list that is + # not a list reads as no list. The strict door's refusal of exactly these + # documents is pinned by the cp1 ledger (tests/test_ownir_validation_fixtures.py, + # section orphaned_awaitables), which is why the schema def is BOUND there. + checks += 1 + degraded = check_facts({"module": "M", "functions": [], "orphaned_awaitables": [ + {"file": "Q.cs", "line": 2 ** 31, "column": 0, "local": "a", "callee": "T.A/0"}, + {"file": "Q.cs", "line": -1, "column": 2 ** 31, "local": "b", "callee": "T.B/0"}, + "not an entry", + {"file": "Q.cs", "line": True, "column": True, "local": "c", "callee": "T.C/0"}]}) + gotd = sorted((x.event, x.line, x.column) for x in degraded if x.code == "OWN053") + if gotd != [("a", 0, None), ("b", 0, None), ("c", 0, None)]: + fails.append(f"OWN053 coordinates must degrade, never clamp or raise: {gotd}") + checks += 1 + notlist = check_facts({"module": "M", "functions": [], "orphaned_awaitables": {"x": 1}}) + if any(x.code == "OWN053" for x in notlist): + fails.append("OWN053: a non-list orphaned_awaitables must read as no list") # (§10 q2) same-name OVERLOADS are merged, not dropped: when EVERY overload of a # name consumes the forwarded arg, a forward to that name resolves to `must`, so a # caller using the local after the handoff is OWN002. Before the merge the name was diff --git a/tests/test_ownir_defensive_limits.py b/tests/test_ownir_defensive_limits.py index 4dfa4f37..1dc028db 100644 --- a/tests/test_ownir_defensive_limits.py +++ b/tests/test_ownir_defensive_limits.py @@ -139,6 +139,12 @@ def _events(depth: int, key: str = "then") -> dict[str, Any]: ("functions[].body[].body[].line", lambda v: {"functions": [{"body": [ {"op": "while", "body": [{"op": "acquire", "line": v}]}]}]}), + # The OWN053 site anchor (P-OWN053-DOOR): the newest door slot, validated + # last. An entry carries its two name slots so the line is the only thing + # the control can fail on. + ("orphaned_awaitables[].line", + lambda v: {"orphaned_awaitables": [ + {"local": "t", "callee": "T.M/0", "file": "a.cs", "line": v}]}), ] # The same paths, for the TYPE rule. Every line field rejects a non-integer; @@ -153,6 +159,9 @@ def _events(depth: int, key: str = "then") -> dict[str, Any]: lambda v: {"functions": [{"params": [{"name": "p", "column": v}]}]}), ("functions[].body[].column", lambda v: {"functions": [{"body": [{"op": "a", "column": v}]}]}), + ("orphaned_awaitables[].column", + lambda v: {"orphaned_awaitables": [ + {"local": "t", "callee": "T.M/0", "file": "a.cs", "line": 1, "column": v}]}), ] @@ -259,10 +268,19 @@ def run() -> int: # registers the sidecar at the doors moves this entry to BOUND; until then # the machinery is doing what it was kept for: the next unbound path is # declared here, not discovered by nobody. + # + # `orphanedAwaitable` (the OWN053 site list, `orphaned_awaitables[]`, §9, + # promoted from ownership-semantics-lab H-29) sat in UNBOUND for exactly one + # commit: the list shipped read by both engines through the tolerant + # coercions only, which was honest about the door that existed. A default-on + # advisory minted from an unvalidated entry is the situation this map + # exists to name, so P-OWN053-DOOR registered the list at both strict doors + # (validated last, after `protocol_functions`) and the entry moved here — + # wiring first, BOUND after, never the other way round. BOUND = {"service": ["line", "ctor_line"], "site": ["line"], "effect": ["line"], "binding": ["line"], "param": ["line"], "protocolEvent": ["line"], "resourceRecord": ["line"], - "flowOp": ["line"]} + "flowOp": ["line"], "orphanedAwaitable": ["line"]} UNBOUND: dict[str, list[str]] = {"sourceSite": ["line"]} # …and the map is CLOSED over the schema, which the per-member checks below diff --git a/tests/test_ownir_validation_fixtures.py b/tests/test_ownir_validation_fixtures.py index 0e8ee4fc..e9267d4f 100644 --- a/tests/test_ownir_validation_fixtures.py +++ b/tests/test_ownir_validation_fixtures.py @@ -1265,6 +1265,205 @@ def _controls() -> list[dict[str, Any]]: "shape"), # …and the coordinate-domain family, appended (insertion-stable). *_domain_controls(), + # …and the OWN053 site list, the last section to join the door + # (P-OWN053-DOOR), appended after everything for the same reason — + # including its own line / column domain controls, which would + # otherwise interleave with the slot tables' output above. + *_orphaned_awaitable_controls(), + ] + + +_ABSENT = object() + + +def _orph(**kw: Any) -> dict[str, Any]: + """A MINIMAL orphaned-awaitable entry: the four required fields, so a + control that drops or breaks one isolates that field. `_ABSENT` removes.""" + rec: dict[str, Any] = {"local": "tx", + "callee": "Npgsql.NpgsqlConnection.BeginTransactionAsync/1", + "file": "Q.cs", "line": 119} + for k, v in kw.items(): + if v is _ABSENT: + rec.pop(k, None) + else: + rec[k] = v + return rec + + +def _orphaned_awaitable_controls() -> list[dict[str, Any]]: + """`orphaned_awaitables[]` — the OWN053 site list (spec/OwnIR.md §9), the + LAST section in BR-D1 order (P-OWN053-DOOR). + + The list shipped UNBOUND with the OWN053 promotion: both engines read it + through the tolerant coercions only, and a default-on advisory was minted + from every entry — so a local that was a list, a callee that was an + object, or a result type that was a number rendered as a real OWN053 built + from the stringification of garbage. These controls are the door that + refuses that. Written, as this file's rule says, from the frozen contract + (paper-eval/h29/p-own053-door-prereg-v1.json) before looking at what the + port does with them. Appended after every existing control so the ledger + stays insertion-stable; for the same reason the line / column DOMAIN + controls of this slot are generated here, in the slot tables' own + below/at/past pattern, rather than by adding a row to those tables. + """ + def D(*entries: Any) -> dict[str, Any]: + return {"ownir_version": OWNIR_VERSION, "orphaned_awaitables": list(entries)} + return [ + # ---- accept twins ------------------------------------------------- + _c("accept-orphaned-empty-list", "orphaned_awaitables", + "the list present and empty: nothing to check", D(), None), + _c("accept-orphaned-minimal", "orphaned_awaitables", + "the four required fields alone (local, callee, file, line) — the " + "optionals are optional", D(_orph()), None), + _c("accept-orphaned-full-family-a", "orphaned_awaitables", + "a complete family-A entry as the extractor writes it: column, " + "method, family and the unwrapped result type", + D(_orph(column=17, method="Q.ScheduleRetryAsync", family="A_owned_result", + result_type="Npgsql.NpgsqlTransaction")), None), + _c("accept-orphaned-family-b-null-result", "orphaned_awaitables", + "a family-B entry: a non-generic awaitable has no result type, so " + "`result_type` is an explicit null, and `method` / `column` may be " + "null too", + D(_orph(local="c", callee="Npgsql.NpgsqlTransaction.CommitAsync/1", + family="B_protocol_lifecycle", result_type=None, method=None, + column=None)), None), + _c("accept-orphaned-two-entries", "orphaned_awaitables", + "entries are independent records; two valid ones are two sites", + D(_orph(), _orph(local="r", callee="Npgsql.NpgsqlCommand.ExecuteReaderAsync/1", + line=120)), None), + # ---- the list ----------------------------------------------------- + _c("orphaned-not-array", "orphaned_awaitables", + "the section must be an array of objects", + {"ownir_version": OWNIR_VERSION, "orphaned_awaitables": {"local": "tx"}}, "shape"), + _c("orphaned-null-section", "orphaned_awaitables", + "…and a PRESENT null is not absent", + {"ownir_version": OWNIR_VERSION, "orphaned_awaitables": None}, "shape"), + _c("orphaned-entry-not-object", "orphaned_awaitables", + "an entry that is a scalar", D(7), "shape"), + _c("orphaned-entry-null", "orphaned_awaitables", + "…and null is not a record", D(None), "shape"), + _c("orphaned-entry-string", "orphaned_awaitables", + "…nor is a bare string, however much it looks like a local", + D("tx"), "shape"), + # ---- the two name slots (identity) ---------------------------------- + _c("orphaned-local-empty", "orphaned_awaitables", + "`local` is the finding's event slot — a name slot, so empty is " + "`identity`", D(_orph(local="")), "identity"), + _c("orphaned-local-absent", "orphaned_awaitables", + "…and it is required", D(_orph(local=_ABSENT)), "identity"), + _c("orphaned-local-not-string", "orphaned_awaitables", + "…and a list is not a name — the exact shape the unbound list " + "rendered as a finding", D(_orph(local=["what", "is", "this"])), "identity"), + _c("orphaned-local-null", "orphaned_awaitables", + "…and null is not a name either", D(_orph(local=None)), "identity"), + _c("orphaned-callee-empty", "orphaned_awaitables", + "`callee` is the finding's handler slot (Type.Member/arity) — a " + "name slot", D(_orph(callee="")), "identity"), + _c("orphaned-callee-absent", "orphaned_awaitables", + "…and it is required", D(_orph(callee=_ABSENT)), "identity"), + _c("orphaned-callee-not-string", "orphaned_awaitables", + "…and an object is not a name", D(_orph(callee={"oops": 1})), "identity"), + # ---- the anchor --------------------------------------------------- + _c("orphaned-file-absent", "orphaned_awaitables", + "`file` is required (the anchor's file)", D(_orph(file=_ABSENT)), "shape"), + _c("orphaned-file-not-string", "orphaned_awaitables", + "…and it must be a string", D(_orph(file=7)), "shape"), + _c("orphaned-file-null", "orphaned_awaitables", + "…a present null included", D(_orph(file=None)), "shape"), + _c("orphaned-line-absent", "orphaned_awaitables", + "`line` is required — unlike the defaulted lines elsewhere, an entry " + "with no line is not a site", D(_orph(line=_ABSENT)), "shape"), + _c("orphaned-line-string", "orphaned_awaitables", + "a string line has no integer form", D(_orph(line="119")), "shape"), + _c("orphaned-line-bool", "orphaned_awaitables", + "…and the bool-is-int trap", D(_orph(line=True)), "shape"), + _c("orphaned-line-null", "orphaned_awaitables", + "…and a present null", D(_orph(line=None)), "shape"), + _c("orphaned-line-float", "orphaned_awaitables", + "…and a float, which is not a coordinate", D(_orph(line=1.5)), "shape"), + _c("orphaned-line-below-i64", "orphaned_awaitables", + "a line with no signed-64 form: `shape` by mechanism, on the other " + "axis from the domain controls below", D(_orph(line=BELOW_I64)), "shape"), + _c("accept-orphaned-column-one", "orphaned_awaitables", + "the 1-based boundary on the site column", D(_orph(column=1)), None), + _c("orphaned-column-zero", "orphaned_awaitables", + "the same column contract as every other column: 0 is a producer " + "bug, not 'unknown'", D(_orph(column=0)), "location"), + _c("orphaned-column-negative", "orphaned_awaitables", + "…negative likewise", D(_orph(column=-1)), "location"), + _c("orphaned-column-bool", "orphaned_awaitables", + "…and the bool-is-int trap on the column", D(_orph(column=True)), "shape"), + _c("orphaned-column-string", "orphaned_awaitables", + "…and a string, which has no integer form to be 1-based about", + D(_orph(column="17")), "shape"), + # ---- the typed optionals ------------------------------------------ + _c("orphaned-method-not-string", "orphaned_awaitables", + "`method` is a string or null", D(_orph(method=7)), "shape"), + _c("orphaned-family-unknown", "orphaned_awaitables", + "`family` is a closed set: a third family does not exist until a " + "witness earns it", D(_orph(family="C_whatever")), "vocabulary"), + _c("orphaned-family-not-string", "orphaned_awaitables", + "…and a non-string is outside the set the same way a non-string " + "parameter effect is", D(_orph(family=7)), "vocabulary"), + _c("orphaned-family-empty", "orphaned_awaitables", + "…and so is the empty string", D(_orph(family="")), "vocabulary"), + _c("orphaned-result-type-not-string", "orphaned_awaitables", + "`result_type` is a string or null — the number the unbound list " + "would have rendered as a type name", D(_orph(result_type=12345)), "shape"), + # ---- order, within an entry and across sections -------------------- + _c("orphaned-order-local-before-callee", "orphaned_awaitables", + "both name slots broken: `local` is checked first — observable only " + "through the message, so the control pins the identity category and " + "the pair is kept for a reader", D(_orph(local="", callee=7)), "identity"), + _c("orphaned-order-callee-before-file", "orphaned_awaitables", + "identity precedes the anchor: a broken callee outranks a missing file", + D(_orph(callee="", file=_ABSENT)), "identity"), + _c("orphaned-order-file-before-line", "orphaned_awaitables", + "…and the file precedes the line", + D(_orph(file=7, line=-1)), "shape"), + _c("orphaned-order-line-before-column", "orphaned_awaitables", + "…the line's domain precedes the column's type (§4.1 order)", + D(_orph(line=-1, column=True)), "location"), + _c("orphaned-order-column-before-family", "orphaned_awaitables", + "…and the column precedes the family vocabulary", + D(_orph(column=0, family="C_whatever")), "location"), + _c("orphaned-order-family-before-result-type", "orphaned_awaitables", + "…and the family precedes the result type", + D(_orph(family="C_whatever", result_type=7)), "vocabulary"), + _c("order-protocol-functions-before-orphaned", "order", + "across sections: `protocol_functions` is validated before " + "`orphaned_awaitables`, so a protocol function without a name " + "(identity) outranks a non-array site list (shape)", + {"ownir_version": OWNIR_VERSION, "protocol_functions": [{"file": "a.cs"}], + "orphaned_awaitables": {"a": 1}}, "identity"), + _c("order-orphaned-is-last", "order", + "…and nothing is validated after it: a document whose only defect " + "is in the site list reports that defect", + {"ownir_version": OWNIR_VERSION, "components": [], "services": [], "effects": [], + "functions": [], "protocols": [], "protocol_functions": [], + "orphaned_awaitables": [_orph(local="")]}, "identity"), + # ---- the line / column domain, in the slot tables' own pattern ------- + _c("accept-orphaned-line-zero", "orphaned_awaitables", + "`0` is the bottom of the domain and means 'unknown / file-level'", + D(_orph(line=LINE_MIN)), None), + _c("accept-orphaned-line-at-int32-max", "orphaned_awaitables", + "…and 2147483647 is the top, accepted exactly", + D(_orph(line=LINE_MAX)), None), + _c("orphaned-line-negative", "orphaned_awaitables", + "one below the bottom: a representable coordinate outside its " + "domain, so `location` rather than `shape`", + D(_orph(line=LINE_MIN - 1)), "location"), + _c("orphaned-line-above-int32", "orphaned_awaitables", + "…and one above the top — the value the unbound list degraded to 0 " + "and the strict door now refuses", + D(_orph(line=ABOVE_LINE_MAX)), "location"), + _c("accept-orphaned-column-at-int32-max", "orphaned_awaitables", + "the top of the column domain, accepted exactly", + D(_orph(column=COLUMN_MAX)), None), + _c("orphaned-column-above-int32", "orphaned_awaitables", + "…and one past it: representable, positive, and outside the " + "domain — `location`, the same axis the 1-based rule is on", + D(_orph(column=COLUMN_MAX + 1)), "location"), ] diff --git a/tests/verdict_surface_inventory.py b/tests/verdict_surface_inventory.py index e9ad88ee..6718fd66 100644 --- a/tests/verdict_surface_inventory.py +++ b/tests/verdict_surface_inventory.py @@ -296,6 +296,13 @@ def _flow_local(bid: str, code: str, kind: str, what: str, tail: str, rf"interprocedural summary inference failed \({N}\); method summaries " rf"skipped — cross-method ownership transfer was not checked this run", ("OWN052",)), + Branch("advisory_own053", "BR-V4", BRIDGE, "OWN053 orphaned-awaitable note", + rf"orphaned awaitable: '{N}' = {N}\(\.\.\.\) is obtained and lost -- never awaited, " + rf"returned, stored or otherwise observed; the operation still runs \({N}\), its " + rf"failure is lost, and a transaction / connection lifecycle call leaves the " + rf"connection in a state nobody can finish\. Await it and keep the result, return " + rf"or store it where it is observed, or express fire-and-forget explicitly", + ("OWN053",)), # the messages the bridge does NOT synthesize: the DI and effect finders' # own `message` property (ownlang/di.py, ownlang/effects.py). Branch("di001_message", "BR-V4", CORE_ANALYSIS, "DI001 captive message (di.py)",