From d43f89f643a35b48082db1f45133f262f164126b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 17:54:18 +0000 Subject: [PATCH 1/2] P-015: reachability codeFlows for flow-local + DI-sourced subscription findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the codeFlows reachability slice (shipped for DI captives in #119) to the other detectors that carry a path, so a SARIF/IDE consumer can answer "where did this come from, and where does it go wrong?" beyond just DI: - flow-local (POOL / local IDisposable): OWN002/003/009/025 now emit a 2-step slice — the Rent/acquire site (origin) -> the violation site (use-after-return, double-return, over-read). A plain OWN001 leak stays a single point (the acquire is its only locus) and gets no flow. New helper `_flow_local_steps`. - DI-sourced subscription escape (OWN014): a cross-file slice — the subscribe site -> where the longer-lived source service was registered (its lifetime is *why* the subscriber escapes), resolved from the services graph. Purely additive: relatedLocations and every existing field/message are unchanged; codeFlows only appears on findings that now carry a path. The static-source `capture` case stays single-point (no second line to point at — honest). Tests: flow-local OWN002 slice on the handoff fixture (acquired -> used-after), and a cross-file DI-source OWN014 escape slice. ownir 166/166; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KkpSWNx7ARLpQeAs13kkyA --- ownlang/ownir.py | 57 ++++++++++++++++++++++++++++++++++++++++++--- tests/test_ownir.py | 27 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 506b47a3..88b712ab 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -1565,6 +1565,34 @@ def _handle_of(diag: object) -> str | None: return subject.split("#", 1)[0] +# the violation-site label per flow-local code, for the 2-step "origin -> manifestation" +# reachability slice (P-015): the Rent/acquire site is where the resource came from, the +# diagnostic line is where the obligation is violated. +_FLOW_LOCAL_VIOLATION = { + "OWN002": "used here after it was released/returned", + "OWN003": "released/returned here a second time", + "OWN009": "may be used here after release on some path", + "OWN025": "viewed here at full length, past what it was rented for", +} + + +def _flow_local_steps(sub: dict[str, Any], code: str, dline: int, + pool: bool) -> tuple[tuple[str, int, str], ...]: + """A reachability slice for a flow-local finding: the Rent/acquire site (where the + resource came from) -> the site where its obligation is violated (`dline`). Only the + use/return/view codes have a distinct second site; a plain leak (OWN001) is a single + point (the acquire itself) and gets no flow. Empty when a line is unknown or the two + sites coincide (then the primary location already says it all).""" + viol = _FLOW_LOCAL_VIOLATION.get(code) + acq = _as_int(sub.get("line", 0)) + if viol is None or acq < 1 or dline < 1 or dline == acq: + return () + f = str(sub.get("file", "?")) + name = sub.get("event", "?") + origin = f"rented '{name}' here" if pool else f"acquired '{name}' here" + return ((f, acq, origin), (f, dline, viol)) + + def check_facts(facts: dict[str, Any]) -> list[Finding]: """Run the core checker over the lowered facts and return findings mapped back to their original C# locations (v0: the `event += without -=` leak). @@ -1584,6 +1612,12 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: mod, handles = to_module(facts) diags = check_module(mod) + # registration site of each DI service, to anchor a subscription-escape slice's + # source hop: the injected event SOURCE is a registered service (P-006 + P-004), so + # the reachability slice can point at where that longer-lived source was registered. + svc_loc = {str(s.get("name", "")): (str(s.get("file", "?")), _as_int(s.get("line", 0))) + for s in (facts.get("services") or []) if isinstance(s, dict)} + findings: list[Finding] = [] for d in diags: if d.severity != Severity.ERROR: @@ -1630,7 +1664,8 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: message=(f"pooled buffer '{name}' is viewed at its full " f"length, past the logical length it was rented for " f"(over-read / over-clear)"), - kind="pooled buffer")) + kind="pooled buffer", + flow=_flow_local_steps(sub, d.code, d.line, True))) continue # An ArrayPool Rent is released by Return (a "pooled buffer"), not Dispose (a # "disposable"); the extractor stamps the acquire's kind so the flow path words @@ -1669,7 +1704,8 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: findings.append(Finding( file=sub["file"], line=int(sub.get("line", 0)), code=d.code, component=component, event=name, handler="", message=msg, - kind="pooled buffer" if pool else "disposable")) + kind="pooled buffer" if pool else "disposable", + flow=_flow_local_steps(sub, d.code, d.line, bool(pool)))) continue if sub.get("di_source_life"): # OWN014 region escape sourced from the DI graph (P-006 + P-004): the @@ -1690,10 +1726,25 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: f"subscription promotes '{component}' to the source's " f"lifetime, so it can never be collected — a captive/region " f"escape (leak, no release path)") + # reachability slice: the subscribe site -> where the longer-lived source was + # registered (its lifetime is why this escapes). The source hop is present only + # when the registration site is known from the services graph. + sub_ln = _as_int(sub.get("line", 0)) + esc_flow: tuple[tuple[str, int, str], ...] = () + if sub_ln >= 1: + steps = [(str(sub["file"]), sub_ln, + f"'{component}' subscribes '{event}' to '{st}' here")] + sf, sl = svc_loc.get(st, ("?", 0)) + if sl >= 1: + steps.append((sf, sl, + f"source '{st}' ({life}) registered here — outlives " + f"'{component}'")) + if len(steps) >= 2: + esc_flow = tuple(steps) findings.append(Finding( file=sub["file"], line=int(sub.get("line", 0)), code=d.code, component=component, event=event, handler=handler, - message=message, kind="subscription token")) + message=message, kind="subscription token", flow=esc_flow)) continue if rkind == "capture": # OWN014 region escape (P-004): the lifetime engine proved the event diff --git a/tests/test_ownir.py b/tests/test_ownir.py index e192aeb0..40f835d4 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -1098,6 +1098,24 @@ def _sub(source: str | None) -> list[Finding]: if [(x.code, x.severity) for x in warn] != [("OWN001", "warning")]: fails.append(f"injected sub without DI info should stay an OWN001 warning, " f"got {[(x.code, x.severity) for x in warn]}") + # P-015: a DI-sourced subscription escape (OWN014) carries a CROSS-FILE slice — the + # subscribe site -> where the longer-lived source service was registered (its lifetime + # is *why* the subscriber escapes). The source hop comes from the services graph. + checks += 1 + esc = check_facts({"ownir_version": 0, "module": "M", "functions": [], + "components": [{"name": "Vm", "file": "VM.cs", "subscriptions": [ + {"event": "bus.Tick", "handler": "OnTick", "line": 11, "released": False, + "resource": "subscription", "source": "injected", "source_type": "IBus"}]}], + "services": [ + {"name": "IBus", "lifetime": "singleton", "deps": [], + "file": "Startup.cs", "line": 7}, + {"name": "Vm", "lifetime": "transient", "deps": ["IBus"], + "file": "Startup.cs", "line": 8}]}) + e14 = next((x for x in esc if x.code == "OWN014"), None) + if e14 is None or e14.flow != ( + ("VM.cs", 11, "'Vm' subscribes 'bus.Tick' to 'IBus' here"), + ("Startup.cs", 7, "source 'IBus' (singleton) registered here — outlives 'Vm'")): + fails.append(f"DI-source OWN014 escape flow wrong: {e14.flow if e14 else None!r}") # --- P-006/2b: COMPOSITIONAL ownership transfer through the bridge. A C# # method's ownership contract (a `consume`/`borrow` parameter) lowers to a @@ -1130,6 +1148,15 @@ def _sub(source: str | None) -> list[Finding]: and "[resource: disposable]" in run_f[0].render()): fails.append(f"use-after-handoff should read as use-after-disposal, got " f"{[x.render() for x in run_f]}") + checks += 1 + # P-015: the flow-local finding carries a reachability slice — where the resource was + # acquired -> where it is used after its obligation moved (the precise violation line the + # primary anchor, at the acquire, does not itself show). + if not run_f or run_f[0].flow != ( + ("Archiver.cs", 24, "acquired 's' here"), + ("Archiver.cs", 26, "used here after it was released/returned")): + fails.append(f"flow-local OWN002 reachability flow wrong: " + f"{run_f[0].flow if run_f else None!r}") # regression (codex review): an undischarged `consume` parameter must MAP to a # finding AT the parameter, not crash check_facts. Before params carried an From dcd71a23da3b20a529f264fbc207537f499e4ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 18:05:43 +0000 Subject: [PATCH 2/2] P-015: cover the OWN025 pooled-buffer Rent->view flow (CodeRabbit #125) The flow-local OWN002 path was asserted via the handoff fixture, but the OWN025 pooled-buffer branch (pool=True, primary anchor at the view site while the flow begins at the Rent site) had no flow assertion. Pin its Rent->view slice so a regression in that distinct branch can't slip through. ownir 167/167; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KkpSWNx7ARLpQeAs13kkyA --- tests/test_ownir.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 40f835d4..d626ccc7 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -1706,6 +1706,15 @@ def _sub(source: str | None) -> list[Finding]: fails.append(f"an `overspan` fact should raise OWN025 at the view line (12) " f"tagged a pooled buffer, got " f"{[(x.code, x.line, x.kind) for x in osp]}") + checks += 1 + # P-015: the OWN025 slice runs Rent site -> view site (the primary anchor is the VIEW + # at line 12, but the flow's first hop is the Rent at line 10) — the pooled-buffer + # branch's own flow path, distinct from the OWN002 case above. + if not osp or osp[0].flow != ( + ("Framer.cs", 10, "rented 'buf' here"), + ("Framer.cs", 12, "viewed here at full length, past what it was rented for")): + fails.append(f"OWN025 Rent->view reachability flow wrong: " + f"{osp[0].flow if osp else None!r}") # --- output surfaces (Уровень 1): the same finding renders for a human, a # GitHub annotation, and an MSBuild/VS Error List line. The format lives