Skip to content

Commit e2ad2dd

Browse files
authored
Merge pull request #127 from PhysShell/claude/d53-bcl-fresh-factories
P-005 D5.3 / P1a: curated BCL fresh-factory table (producer side of Tier B)
2 parents 1e8bb8b + fe74309 commit e2ad2dd

3 files changed

Lines changed: 162 additions & 7 deletions

File tree

docs/notes/d5-ownership-transfer.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,31 @@ escape-without-transfer and all `unknown`/`may` lower to **silence** in the defa
333333
cumulative), and the early-return guard shape (`guard` — stays a loud OWN030 raise rather than a
334334
false positive). (Bridge branch-scope fix: Codex P2 on #116; loop exclusion Codex P1, hoist
335335
safety predicate + pool-kind preservation CodeRabbit on #120.)
336-
- **D5.3 — Tier B breadth.** The rest of the documented BCL ownership table + `fresh`
337-
factories.
336+
- **D5.3 — Tier B breadth.**
337+
- **Producer side — `fresh` factories (shipped, first slice).** A curated
338+
`_BCL_FRESH_FACTORIES` table in the OwnIR bridge (`ownir.py`) marks well-known BCL
339+
factories whose return the caller owns (`File.OpenRead/OpenText/OpenWrite/Open/Create/
340+
CreateText/AppendText`). A `call` to one binds a `fresh` result via the SAME `_callee_
341+
returns_fresh` path the first-party T1 inference uses (now the single source of truth for
342+
the leak pre-scan, branch-hoist safety, and lowering), so a leaked `var s =
343+
File.OpenRead(p)` surfaces as OWN001 *at the factory call* — invisible before (no body to
344+
infer from; see `corpus-benchmark.md`). Matched conservatively (Codex): ONLY the bare
345+
`File.Method` or the fully-qualified `System.IO.File.Method` — a same-named factory in
346+
another namespace (`MyCompany.File.OpenRead`) is **not** a match, so we never fabricate
347+
ownership for a look-alike. A **first-party summary overrides** the table (`_callee_
348+
returns_fresh` trusts a known body over Tier B), and a first-party **wrapper** that
349+
returns a factory result (`Make(){ return File.OpenRead(p) }`) is itself `fresh`, so a
350+
dropped `Make()` leaks too (the return skeleton propagates BCL freshness instead of
351+
forwarding to the external, unsummarizable callee). Pure factories only — overload-
352+
ambiguous *wrappers* that adopt an arg (`new StreamReader(stream)`) are excluded (sink/T4).
353+
Tests in `test_ownir.py` (leak / disposed-clean / use-after-dispose / namespace-qualified /
354+
non-System.IO look-alike rejected / first-party override / wrapper-fresh recall / a
355+
non-disposable `File.ReadAllText` making no claim).
356+
- **Sink side — `leaveOpen` breadth (remaining, extractor-side).** The documented
357+
consume/borrow table (`StreamReader`/`StreamWriter`/`CryptoStream`/… by the `leaveOpen`
358+
bool literal) rides the existing `$consume`/`$borrow` channel (D5.1b); its breadth is a
359+
C#-extractor table (the bool literal is a per-call-site fact the extractor sees), so it is
360+
CI/C#-only, not a pure-Python slice.
338361
- **D5.4 — T4 wrap/adopt** (the obligation-identity model, §11). Lands in a **three-commit
339362
cadence** so the core change is de-risked: **(step 0)** a *no-op identity refactor*
340363
move resource state from per-binding to per-RID with a 1:1 binding↔RID mapping, behaviour

ownlang/ownir.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,12 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str]) -> ReturnSkeleton:
10411041
(v,) = tuple(returned)
10421042
callee = call_results.get(v)
10431043
if callee and v not in param_names and v not in acquired:
1044+
if _is_bcl_fresh_factory(callee):
1045+
# a thin wrapper returning a BCL factory's result is itself `fresh` — the
1046+
# caller owns it (Codex). Without this the return is a `forward` to an
1047+
# external (bodyless) callee, which the solver degrades to `unknown`, so a
1048+
# dropped `Make()` whose body is `return File.OpenRead(p)` leaks invisibly.
1049+
return ReturnSkeleton("fresh")
10441050
return ReturnSkeleton("forward", callee=callee)
10451051
return ReturnSkeleton() # not provably owned -> no claim
10461052

@@ -1095,6 +1101,49 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str]) -> ReturnSkeleton:
10951101
_SINK_PATH_ACTION = {"$consume": "dispose", "$borrow": "borrow"}
10961102

10971103

1104+
# Tier B (P-005 D5.3 / P1a contracts): a curated table of well-known BCL *factories* whose
1105+
# return the caller OWNS — the producer half of the boundary contract (the consume/borrow
1106+
# *sink* half rides the `$consume`/`$borrow` channel above). These are pure factories: the
1107+
# result is a fresh owned `IDisposable` and the arguments are not resources, so a leaked
1108+
# `var s = File.OpenRead(p)` now surfaces as an OWN001 leak AT the factory call — it was
1109+
# invisible before (no body to infer `fresh` from; see docs/notes/corpus-benchmark.md).
1110+
# Overload-ambiguous *wrappers* that ADOPT an argument (e.g. `new StreamReader(stream)`) are
1111+
# deliberately excluded — that is the sink / T4 case, not a pure factory. Keyed by
1112+
# `Type.Method`; a callee matches on its last two dotted segments so a namespace-qualified
1113+
# `System.IO.File.OpenRead` resolves the same.
1114+
_BCL_FRESH_FACTORIES = frozenset({
1115+
"File.OpenRead", "File.OpenText", "File.OpenWrite",
1116+
"File.Open", "File.Create", "File.CreateText", "File.AppendText",
1117+
})
1118+
# the fully-qualified `System.IO.File.*` identities — accepted alongside the bare forms.
1119+
_BCL_FRESH_FQNS = frozenset("System.IO." + e for e in _BCL_FRESH_FACTORIES)
1120+
1121+
1122+
def _is_bcl_fresh_factory(callee: str) -> bool:
1123+
"""True if `callee` names a curated BCL factory whose return the caller owns. Accepts
1124+
ONLY the bare `Type.Method` (`File.OpenRead`) or the fully-qualified `System.IO.File.*`
1125+
identity (with an optional `global::` qualifier) — a same-named type in another namespace
1126+
(`MyCompany.File.OpenRead`) is NOT a match. Precision-first: we never fabricate ownership
1127+
for a non-BCL look-alike (Codex / CodeRabbit)."""
1128+
if not callee:
1129+
return False
1130+
name = callee.removeprefix("global::")
1131+
return name in _BCL_FRESH_FACTORIES or name in _BCL_FRESH_FQNS
1132+
1133+
1134+
def _callee_returns_fresh(callee: str, mos: dict[str, Any] | None) -> bool:
1135+
"""Whether a `call` to `callee` yields a fresh owned result the caller must release.
1136+
A first-party summary is AUTHORITATIVE — if one exists we trust its `returns`, so a
1137+
same-named first-party `File.OpenRead` (Tier A) overrides the BCL table (Tier B) and is
1138+
never given a fabricated `fresh` (Codex). Only a callee we have no body for falls back to
1139+
the curated BCL factory table. The single source of truth shared by the leak pre-scan,
1140+
the branch-hoist safety walk, and the flow lowering, so all three agree."""
1141+
summ = mos.get(callee) if (mos is not None and callee) else None
1142+
if summ is not None:
1143+
return getattr(summ, "returns", None) == "fresh"
1144+
return _is_bcl_fresh_factory(callee)
1145+
1146+
10981147
def _param_signals(pname: str, nodes: Any) -> tuple[bool, bool, bool]:
10991148
"""Scan a flow body for how parameter `pname` is treated, returning
11001149
(released, handed-to-a-call, used). Recurses into if/while branches so a
@@ -1327,8 +1376,7 @@ def acquires(n: dict[str, Any]) -> bool:
13271376
if n.get("op") == "acquire" and str(n.get("var", "")) == name:
13281377
return True
13291378
if n.get("op") == "call" and str(n.get("result", "")) == name:
1330-
summ = mos.get(str(n.get("callee", ""))) if mos is not None else None
1331-
return summ is not None and getattr(summ, "returns", None) == "fresh"
1379+
return _callee_returns_fresh(str(n.get("callee", "")), mos)
13321380
return False
13331381

13341382
def analyze(seq: Any, acquired: bool) -> tuple[bool, bool]:
@@ -1396,8 +1444,7 @@ def fresh_result(n: dict[str, Any]) -> str | None:
13961444
callee, res = n.get("callee"), n.get("result")
13971445
if not (isinstance(res, str) and res and isinstance(callee, str) and callee):
13981446
return None
1399-
summ = mos.get(callee) if mos is not None else None
1400-
return res if (summ is not None and getattr(summ, "returns", None) == "fresh") else None
1447+
return res if _callee_returns_fresh(callee, mos) else None
14011448

14021449
def note_ref(name: str, depth: int) -> None:
14031450
if name not in ref_depth or depth < ref_depth[name]:
@@ -1561,7 +1608,7 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str,
15611608
if isinstance(result, str) and result and result not in hoisted:
15621609
localmap.pop(result, None)
15631610
if (isinstance(result, str) and result and result not in hoisted
1564-
and summ is not None and getattr(summ, "returns", None) == "fresh"):
1611+
and _callee_returns_fresh(callee, mos)):
15651612
handle = f"loc_{loc[0]}"
15661613
loc[0] += 1
15671614
localmap[result] = handle

tests/test_ownir.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,91 @@ def _sub(source: str | None) -> list[Finding]:
15131513
f"got {[(x.component, x.code) for x in unk]}")
15141514
except OwnIRError as e:
15151515
fails.append(f"D5.2: a call to an unknown callee must not crash (OWN040), got {e!r}")
1516+
# Tier B (D5.3 / P1a): a curated BCL *factory* (`File.OpenRead` &c.) returns an owned
1517+
# IDisposable even with no first-party body, so a leaked `var s = File.OpenRead(p)` is
1518+
# OWN001 AT the factory call (invisible before this table) — the producer half of the
1519+
# boundary contract. Contrast the unknown-callee case just above, which makes no claim.
1520+
def _bcl(body: list) -> list:
1521+
return check_facts({"module": "M", "functions": [
1522+
{"name": "Svc.Do", "file": "Bcl.cs", "body": body}]})
1523+
checks += 1
1524+
bleak = [(x.code, x.line, x.kind) for x in _bcl(
1525+
[{"op": "call", "callee": "File.OpenRead", "args": ["p"], "result": "s", "line": 5}])]
1526+
if bleak != [("OWN001", 5, "disposable")]:
1527+
fails.append(f"Tier B: a leaked BCL factory result must be OWN001@5 disposable, "
1528+
f"got {bleak}")
1529+
checks += 1
1530+
if _bcl([{"op": "call", "callee": "File.OpenRead", "args": ["p"], "result": "s", "line": 5},
1531+
{"op": "release", "var": "s", "line": 6}]):
1532+
fails.append("Tier B: a disposed BCL factory result must be clean (silent)")
1533+
checks += 1
1534+
buar = [(x.code, x.line) for x in _bcl(
1535+
[{"op": "call", "callee": "File.OpenRead", "args": ["p"], "result": "s", "line": 5},
1536+
{"op": "release", "var": "s", "line": 6},
1537+
{"op": "use", "var": "s", "line": 7}])]
1538+
if buar != [("OWN002", 5)]:
1539+
fails.append(f"Tier B: using a BCL factory result after dispose must be OWN002@5, "
1540+
f"got {buar}")
1541+
checks += 1
1542+
# a namespace-qualified callee resolves on its last two segments (`Type.Method`).
1543+
nsq = [(x.code, x.line) for x in _bcl(
1544+
[{"op": "call", "callee": "System.IO.File.Create", "args": ["p"],
1545+
"result": "s", "line": 9}])]
1546+
if nsq != [("OWN001", 9)]:
1547+
fails.append(f"Tier B: a namespace-qualified BCL factory must resolve, got {nsq}")
1548+
checks += 1
1549+
# a non-disposable BCL method (`File.ReadAllText` -> string) is NOT a factory — no false
1550+
# acquire of its result, stays silent (precision-first: the table is owned-returns only).
1551+
if _bcl([{"op": "call", "callee": "File.ReadAllText", "args": ["p"],
1552+
"result": "t", "line": 3}]):
1553+
fails.append("Tier B: a non-disposable BCL method must not be treated as a factory")
1554+
checks += 1
1555+
# PRECISION (Codex): a same-named factory in ANOTHER namespace is NOT System.IO.File, so
1556+
# the match must not be a loose suffix — only bare `File.X` and `System.IO.File.X` count.
1557+
# A `MyCompany.File.OpenRead` returning a plain value must NOT fabricate a false OWN001.
1558+
if _bcl([{"op": "call", "callee": "MyCompany.File.OpenRead", "args": ["p"],
1559+
"result": "s", "line": 5}]):
1560+
fails.append("Tier B precision: a non-System.IO `*.File.OpenRead` must NOT match")
1561+
checks += 1
1562+
# a `global::`-qualified System.IO.File factory IS the BCL identity (the qualifier is
1563+
# stripped); a `global::`-qualified non-System.IO look-alike still must NOT match.
1564+
gq = [(x.code, x.line) for x in _bcl([{"op": "call",
1565+
"callee": "global::System.IO.File.OpenRead", "args": ["p"],
1566+
"result": "s", "line": 4}])]
1567+
if gq != [("OWN001", 4)]:
1568+
fails.append(f"Tier B: a `global::System.IO.File.*` factory must match, got {gq}")
1569+
if _bcl([{"op": "call", "callee": "global::MyCompany.File.OpenRead", "args": ["p"],
1570+
"result": "s", "line": 4}]):
1571+
fails.append("Tier B precision: `global::`-qualified non-System.IO must NOT match")
1572+
checks += 1
1573+
# OVERRIDE (Codex): a first-party summary is authoritative — a first-party `File.OpenRead`
1574+
# that returns its parameter is NOT fresh, so a caller dropping its result is clean; the
1575+
# table must not fabricate ownership for a callee whose body we can see.
1576+
ov_fp = check_facts({"module": "M", "functions": [
1577+
{"name": "File.OpenRead", "file": "B.cs", "params": [{"name": "x", "line": 1}],
1578+
"body": [{"op": "return", "var": "x", "line": 2}]},
1579+
{"name": "Caller", "file": "B.cs", "body": [
1580+
{"op": "acquire", "var": "a", "line": 10},
1581+
{"op": "call", "callee": "File.OpenRead", "args": ["a"],
1582+
"result": "r", "line": 11},
1583+
{"op": "release", "var": "a", "line": 12}]}]})
1584+
if ov_fp:
1585+
fails.append(f"Tier B: a first-party summary must override the BCL table, "
1586+
f"got {[(x.component, x.code) for x in ov_fp]}")
1587+
checks += 1
1588+
# RECALL (Codex): a first-party wrapper that returns a BCL factory result is itself fresh,
1589+
# so a caller dropping `Make()` leaks OWN001 — the return skeleton propagates BCL freshness
1590+
# rather than degrading to a `forward` to the external factory (-> unknown -> invisible).
1591+
wrap = [(x.component, x.line, x.code) for x in check_facts({"module": "M", "functions": [
1592+
{"name": "Make", "file": "B.cs", "body": [
1593+
{"op": "call", "callee": "File.OpenRead", "args": ["p"],
1594+
"result": "s", "line": 2},
1595+
{"op": "return", "var": "s", "line": 3}]},
1596+
{"name": "Caller2", "file": "B.cs", "body": [
1597+
{"op": "call", "callee": "Make", "args": [], "result": "r", "line": 10}]}]})]
1598+
if wrap != [("Caller2", 10, "OWN001")]:
1599+
fails.append(f"Tier B: a wrapper returning a BCL factory result must be fresh "
1600+
f"(caller leak OWN001@10), got {wrap}")
15161601
# OVERWRITE kills the prior binding (CodeRabbit): `acquire x; x = Unknown(); release x`
15171602
# — the call's result reuses an owned local and the call is dropped (unknown callee),
15181603
# so the ORIGINAL x leaks (its reference is lost), not read as clean. The release after

0 commit comments

Comments
 (0)