From 77bfc07d694846cbefe594ad906eabcfa95882a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 10:10:21 +0000 Subject: [PATCH 1/3] feat(p1a): curate XmlReader/XmlWriter/JsonDocument as owned factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1a stdlib contract pack (producer side): extend the curated owned-returning factory recognition with three high-value, low-FP BCL families whose result the caller must dispose — `XmlReader.Create`, `XmlWriter.Create` (System.Xml) and `JsonDocument.Parse` (System.Text.Json, which pools memory and is a common real leak when dropped). Same producer-half contract as File.Open* / crypto Create*: a `var doc = JsonDocument.Parse(json)` that drops `doc` now surfaces OWN001 at the factory call, and use/double-dispose of the result is OWN002/OWN003. Both synced halves are updated in lockstep: - extractor `IsOwningFactory` (Program.cs) — gates whether to EMIT the factory call fact, by resolved symbol (static + result implements IDisposable + the type/namespace), so a Task-returning `JsonDocument.ParseAsync` is excluded; - bridge `_BCL_FRESH_BY_NS` (ownir.py) — recognises the callee name as `fresh`. Bridge half is covered by new tests (leak → OWN001 for each, bare and namespace-qualified; a disposed result stays clean): ownir 206/206, ownership 44/44, mypy --strict, ruff all green locally. NOTE: this environment has no dotnet, so the extractor (C#) half is not built locally — it is verified by CI (golden C# compiles & runs, C# leak extractor, and the real-C# corpus benchmark for recall/specificity). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- frontend/roslyn/OwnSharp.Extractor/Program.cs | 16 +++++++++++++++- ownlang/ownir.py | 11 +++++++++++ tests/test_ownir.py | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 73b08dfe..7fcdbcbe 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -2260,9 +2260,15 @@ void Visit(ExpressionSyntax e) // IncrementalHash.CreateHash(), ... (guarded by static + Create-prefixed + the RESULT // implementing IDisposable + the crypto namespace, so an instance `CreateEncryptor()` or // a non-IDisposable `CreateFromName()` is never mistaken for one). +// * P1a stdlib pack: System.Xml `XmlReader.Create`/`XmlWriter.Create` -> a fresh owned +// reader/writer; System.Text.Json `JsonDocument.Parse` -> a JsonDocument that pools memory +// and must be disposed. Gated on the RESULT implementing IDisposable, so a Task-returning +// `JsonDocument.ParseAsync` is excluded. // Curated + symbol-resolved, so a borrowed/cached disposable handed back by some other API is // never mistaken for an owned acquire (precision over recall — the set grows only as -// ownership is certain). +// ownership is certain). Kept in lockstep with the bridge table `_BCL_FRESH_BY_NS` +// (ownlang/ownir.py): the extractor decides whether to EMIT the factory call fact, the bridge +// recognises the callee name as `fresh`. static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) { if (e is not InvocationExpressionSyntax i @@ -2278,6 +2284,14 @@ static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) && ImplementsIDisposable(sym.ReturnType) && IsInNamespace(sym.ContainingType, "System", "Security", "Cryptography")) return true; + if (sym.IsStatic && ImplementsIDisposable(sym.ReturnType) + && ((sym.Name == "Create" + && sym.ContainingType is { Name: "XmlReader" or "XmlWriter" } xt + && IsInNamespace(xt, "System", "Xml")) + || (sym.Name == "Parse" + && sym.ContainingType is { Name: "JsonDocument" } jt + && IsInNamespace(jt, "System", "Text", "Json")))) + return true; return false; } diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 5d99b4b1..0cac20f0 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -1185,6 +1185,17 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str], "SHA1.Create", "SHA256.Create", "SHA384.Create", "SHA512.Create", "MD5.Create", "Aes.Create", "RSA.Create", "ECDsa.Create", ), + # P1a (stdlib contract pack): more well-known static factories whose result the caller + # OWNS and must dispose. `XmlReader`/`XmlWriter.Create` return a fresh reader/writer; a + # `JsonDocument` from `Parse` pools memory and must be disposed (a `var doc = + # JsonDocument.Parse(json)` that drops `doc` is a common real leak). Kept in lockstep with + # the extractor's `IsOwningFactory` (frontend/roslyn/.../Program.cs). + "System.Xml": ( + "XmlReader.Create", "XmlWriter.Create", + ), + "System.Text.Json": ( + "JsonDocument.Parse", + ), } _BCL_FRESH_FACTORIES = frozenset(e for es in _BCL_FRESH_BY_NS.values() for e in es) # the fully-qualified identities (each under its real namespace), accepted beside the bare forms. diff --git a/tests/test_ownir.py b/tests/test_ownir.py index bcb800d0..4c741090 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -1691,6 +1691,22 @@ def _bcl(body: list) -> list: if _bcl([{"op": "call", "callee": "global::MyCompany.File.OpenRead", "args": ["p"], "result": "s", "line": 4}]): fails.append("Tier B precision: `global::`-qualified non-System.IO must NOT match") + # P1a (stdlib pack): more curated owned-returning factories. A dropped XmlReader/XmlWriter/ + # JsonDocument result leaks at the factory call (OWN001), the same producer-side contract as + # File.Open* — both the bare `Type.Method` and the namespace-qualified identity resolve. + for fresh_callee, ln in (("XmlReader.Create", 5), ("XmlWriter.Create", 6), + ("JsonDocument.Parse", 7), + ("System.Text.Json.JsonDocument.Parse", 8)): + checks += 1 + leak = [(x.code, x.line) for x in _bcl( + [{"op": "call", "callee": fresh_callee, "args": ["a"], "result": "s", "line": ln}])] + if leak != [("OWN001", ln)]: + fails.append(f"P1a: a leaked `{fresh_callee}` result must be OWN001@{ln}, got {leak}") + # disposing the P1a factory result is clean (no false leak), proving it is a real acquire. + checks += 1 + if _bcl([{"op": "call", "callee": "XmlReader.Create", "args": ["a"], "result": "s", "line": 5}, + {"op": "release", "var": "s", "line": 6}]): + fails.append("P1a: a disposed XmlReader.Create result must be clean (silent)") checks += 1 # OVERRIDE (Codex): a first-party summary is authoritative — a first-party `File.OpenRead` # that returns its parameter is NOT fresh, so a caller dropping its result is clean; the From bac905a5bdd08492d9b7d1c32d9bac4116fe37e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 10:14:47 +0000 Subject: [PATCH 2/3] test(p1a): pin fully-qualified System.Xml factory regressions CodeRabbit: the P1a leak loop only exercised the FQN form for JsonDocument.Parse. Add System.Xml.XmlReader.Create and System.Xml.XmlWriter.Create FQNs too, so each new table entry is pinned end-to-end (bare + namespace-qualified). ownir 208/208, ruff clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- tests/test_ownir.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 4c741090..60ae713c 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -1694,9 +1694,12 @@ def _bcl(body: list) -> list: # P1a (stdlib pack): more curated owned-returning factories. A dropped XmlReader/XmlWriter/ # JsonDocument result leaks at the factory call (OWN001), the same producer-side contract as # File.Open* — both the bare `Type.Method` and the namespace-qualified identity resolve. - for fresh_callee, ln in (("XmlReader.Create", 5), ("XmlWriter.Create", 6), - ("JsonDocument.Parse", 7), - ("System.Text.Json.JsonDocument.Parse", 8)): + for fresh_callee, ln in (("XmlReader.Create", 5), + ("System.Xml.XmlReader.Create", 6), + ("XmlWriter.Create", 7), + ("System.Xml.XmlWriter.Create", 8), + ("JsonDocument.Parse", 9), + ("System.Text.Json.JsonDocument.Parse", 10)): checks += 1 leak = [(x.code, x.line) for x in _bcl( [{"op": "call", "callee": fresh_callee, "args": ["a"], "result": "s", "line": ln}])] From 83edff65840824766a78d7af7834644cd4165315 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 10:19:13 +0000 Subject: [PATCH 3/3] fix(p1a): don't treat disposable-input Xml/Json factory overloads as pure factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XmlReader.Create / XmlWriter.Create / JsonDocument.Parse have overloads that take a caller-owned disposable input (a Stream / TextReader / TextWriter) which they do NOT dispose by default (XmlReaderSettings.CloseInput and XmlWriterSettings.CloseOutput default false; JsonDocument.Parse never closes its stream). Classifying those as pure owned factories let the extractor's escape pass drop the input argument from leak tracking, suppressing a real leak of the caller-owned stream — e.g. `var fs = File.OpenRead(p); var xr = XmlReader.Create(fs); xr.Dispose();` leaked `fs` invisibly. (Codex P2.) Gate the new factory branch on `!AnyDisposableArgument`: decline the factory claim whenever any argument resolves to an IDisposable. The common string / URI / path overloads have no disposable arg and still resolve (recall preserved there); precision over recall on the wrapping overloads — never suppress an input leak. C#-only (no dotnet locally): verified by CI (golden C#, C# leak extractor, corpus benchmark). Bridge table + tests unchanged — the table is consulted only when the extractor emits the factory fact, which now excludes the disposable-input overloads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- frontend/roslyn/OwnSharp.Extractor/Program.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 7fcdbcbe..ba602b2c 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -2290,11 +2290,32 @@ static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) && IsInNamespace(xt, "System", "Xml")) || (sym.Name == "Parse" && sym.ContainingType is { Name: "JsonDocument" } jt - && IsInNamespace(jt, "System", "Text", "Json")))) + && IsInNamespace(jt, "System", "Text", "Json"))) + // ...but ONLY the overloads that take no disposable input. The Stream/TextReader/ + // TextWriter overloads do NOT own that input (XmlReaderSettings.CloseInput / + // XmlWriterSettings.CloseOutput default false, JsonDocument.Parse never closes its + // stream), so claiming a pure factory there would let the escape pass drop the + // caller-owned input and suppress its leak (Codex). The common string/URI/path + // overloads have no disposable arg and still resolve. (precision over recall.) + && !AnyDisposableArgument(i, model)) return true; return false; } +// True if any argument to the call resolves to a type implementing IDisposable — a disposable +// the callee might NOT take ownership of, so an enclosing owned-factory claim must be declined +// rather than silently drop that argument from leak tracking. +static bool AnyDisposableArgument(InvocationExpressionSyntax inv, SemanticModel model) +{ + foreach (var arg in inv.ArgumentList.Arguments) + { + var t = model.GetTypeInfo(arg.Expression).Type; + if (t is not null && ImplementsIDisposable(t)) + return true; + } + return false; +} + // Is the type `t` declared in the namespace named by `parts` (outermost-first), e.g. // IsInNamespace(t, "System", "IO") for System.IO? Walks the containing-namespace chain // and requires it to bottom out at the global namespace (so `System.IO` matches but a