55This module is pure **data + algorithm**: it computes a Method Ownership Summary
66(MOS) for each first-party method from per-method *local evidence* (a "skeleton"
77of what a body directly does with each disposable parameter and what it returns),
8- resolving the parts that depend on other methods' summaries via a **depth-capped
9- bottom-up resolution** over the call graph.
10-
11- D5.0 deliberately does **nothing** to findings — no extractor wiring, no
12- behaviour change. It defines the summary vocabulary and the solver so the lattice
13- can be unit-tested in pure Python (the synthetic-flow discipline, lifted to the
14- effect level). Lowering a summary into the core's `consume`/`borrow`/`acquire`
15- vocabulary is D5.1+; the wrapper/alias (`aliasOf`) obligation-identity model is
16- D5.4 (see the note's §11).
17-
18- The skeleton is the extractor-facing input; here it is hand-authored in tests.
8+ resolving the parts that depend on other methods' summaries by a **summary
9+ fixpoint over the strongly-connected-component condensation** of the call graph.
10+
11+ The summary is *context-insensitive*: each method has exactly one MOS, independent
12+ of who calls it or how deep the call sits. That is what makes the computation both
13+ linear (each method resolved once and reused — no per-call re-descent) and exact
14+ on recursion: cycles are solved to their least fixpoint on the four-point lattice
15+ rather than truncated. There is **no depth cap** — the SCC condensation bounds the
16+ work without one (an earlier slice capped the recursive descent at depth 3 purely
17+ to dodge the exponential a memo-less re-descent would otherwise hit on diamond call
18+ graphs; the condensation removes both the blowup and the cap-induced false
19+ `unknown`s on deep chains).
20+
21+ Lowering a summary into the core's `consume`/`borrow`/`acquire` vocabulary lives in
22+ the bridge (`ownir.py`, D5.1+: `must`→consume, `no`→borrow, `may`/`unknown`→plain);
23+ the wrapper/alias (`aliasOf`) obligation-identity model is D5.4. The skeleton is the
24+ extractor-facing input; here it is hand-authored in tests.
1925
2026Precision note: a parameter is reported `must`-transfer only when ownership leaves
21- the caller on **every** normal-return path the skeleton lists. A recursive forward
22- edge contributes *no* transfer evidence (the cycle is broken at `no`), and a chain
23- deeper than the cap, or a forward to an unsummarized (extern) callee, degrades to
24- `unknown` — never to a guessed `must`. That keeps the project's precision-first
25- stance: we only ever *claim* transfer when we can prove it.
27+ the caller on **every** normal-return path. The fixpoint seeds each recursive edge
28+ at the lattice bottom (⊥, "no evidence yet") rather than at a spurious `no`, so a
29+ method that disposes on its base case and recurses otherwise resolves to `must`
30+ (every *terminating* path disposes), and mutual recursion that never disposes
31+ settles at `no`. A forward to an unsummarized (extern) callee is the only residual
32+ `unknown`, and `solve_with_log` surfaces every such boundary — never a guessed
33+ `must`. That keeps the project's precision-first stance: we only ever *claim*
34+ transfer when we can prove it.
2635"""
2736
2837from __future__ import annotations
2938
30- from collections .abc import Iterable
39+ from collections .abc import Iterable , Iterator
3140from dataclasses import dataclass , field
3241from enum import StrEnum
3342
34- # Default interprocedural forward-chain depth, matching CA2000's
35- # `max_interprocedural_method_call_chain` default. Beyond it, a forward degrades
36- # to `unknown` (and is logged) rather than spending unbounded work.
37- DEFAULT_CAP = 3
38-
3943
4044class Transfer (StrEnum ):
4145 """Did ownership of a disposable parameter leave the caller?
@@ -147,90 +151,102 @@ def to_dict(self) -> dict[str, object]:
147151 }
148152
149153
150- def _resolve_param (key : str , i : int , depth : int , stack : frozenset [str ],
151- cap : int , sk : dict [str , MethodSkeleton ],
152- capped : list [str ]) -> Transfer :
153- skel = sk .get (key )
154- if skel is None :
155- return Transfer .UNKNOWN
156- # `i` is the callee's *logical* parameter index (`ParamSkeleton.index`), which
157- # need not equal the tuple offset — a skeleton may list only the disposable /
158- # interesting params, so a wrapper `Create(cmd, reader)` can carry just index 1.
159- # Resolve by `.index`, never by position.
160- p = next ((q for q in skel .params if q .index == i ), None )
161- if p is None :
162- return Transfer .UNKNOWN
163- if not p .disposable :
164- return Transfer .NO
165- if not p .paths :
166- return Transfer .NO # nothing happens to it -> kept (borrowed)
167- verdict : Transfer | None = None
168- for a in p .paths :
169- verdict = _path_verdict (a , depth , stack , cap , sk , capped ) if verdict is None \
170- else join (verdict , _path_verdict (a , depth , stack , cap , sk , capped ))
171- return verdict if verdict is not None else Transfer .NO
172-
173-
174- def _path_verdict (a : PathAction , depth : int , stack : frozenset [str ], cap : int ,
175- sk : dict [str , MethodSkeleton ], capped : list [str ]) -> Transfer :
176- if a .kind in ("dispose" , "adopt" , "return" ):
177- return Transfer .MUST # ownership left the caller on this path
178- if a .kind == "borrow" :
179- return Transfer .NO
180- if a .kind == "forward" :
181- if a .callee not in sk :
182- return Transfer .UNKNOWN # extern / unsummarized callee
183- if depth + 1 >= cap :
184- capped .append (f"{ a .callee } #{ a .arg } (depth { depth + 1 } >= cap { cap } )" )
185- return Transfer .UNKNOWN
186- if a .callee in stack :
187- return Transfer .NO # recursion: this edge carries no transfer evidence
188- return _resolve_param (a .callee , a .arg , depth + 1 , stack | {a .callee },
189- cap , sk , capped )
190- return Transfer .UNKNOWN
191-
192-
193- def _resolve_return (key : str , depth : int , stack : frozenset [str ], cap : int ,
194- sk : dict [str , MethodSkeleton ], capped : list [str ]) -> str :
195- skel = sk .get (key )
196- if skel is None :
197- return "unknown"
198- r = skel .ret
199- if r .kind == "fresh" :
200- return "fresh"
201- if r .kind == "aliasOf" :
202- return f"aliasOf:{ r .arg } "
203- if r .kind == "aliased" :
204- return "aliased"
205- if r .kind == "forward" :
206- if r .callee not in sk :
207- return "unknown"
208- if depth + 1 >= cap :
209- capped .append (f"return { r .callee } (depth { depth + 1 } >= cap { cap } )" )
210- return "unknown"
211- if r .callee in stack :
212- return "unknown"
213- inner = _resolve_return (r .callee , depth + 1 , stack | {r .callee }, cap , sk , capped )
214- if inner .startswith ("aliasOf:" ):
215- # `inner` aliases one of the *callee's* params; remapping it to one of
216- # OUR args needs the call's argument mapping, which the skeleton does not
217- # carry yet (it arrives with the obligation-identity model in D5.4). Until
218- # then, never propagate a wrong index — degrade to unknown (precision-safe:
219- # nothing is acquired/aliased at lowering).
220- return "unknown"
221- return inner # fresh / aliased / none / unknown propagate as-is
222- if r .kind == "none" :
223- return "none" # explicit no-owned-return
224- return "unknown" # an unrecognised kind fails closed, never silently "none"
225-
226-
227- def solve_with_log (skeletons : Iterable [MethodSkeleton ], * ,
228- cap : int = DEFAULT_CAP ) -> tuple [dict [str , MethodSummary ], list [str ]]:
229- """Resolve every method's MOS from its skeleton plus its callees' skeletons.
230-
231- Returns (summaries-by-key, capped-log). The log names every forward that hit
232- the depth cap, so a run can surface what it gave up on (no silent
233- truncation)."""
154+ # A parameter is resolved by `.index` (its logical `ParamSkeleton.index`), never by
155+ # tuple offset: a skeleton may list only the disposable/interesting params, so a
156+ # wrapper `Create(cmd, reader)` can carry just index 1.
157+ ParamKey = tuple [str , int ] # (method key, logical parameter index)
158+
159+ # `None` is the lattice bottom ⊥ ("no evidence yet") used only as the fixpoint seed
160+ # on a recursive edge. It never escapes a solved summary — it is mapped to `no` at
161+ # finalization (a parameter nothing demonstrably consumes is kept = borrowed).
162+ def _join_opt (a : Transfer | None , b : Transfer | None ) -> Transfer | None :
163+ """`join` lifted to the bottom-extended lattice: ⊥ (None) is the identity."""
164+ if a is None :
165+ return b
166+ if b is None :
167+ return a
168+ return join (a , b )
169+
170+
171+ def _sccs (adj : dict [str , set [str ]]) -> list [list [str ]]:
172+ """Tarjan's SCCs, returned bottom-up (every component precedes its callers).
173+
174+ Iterative (no Python recursion limit on deep call graphs). Tarjan emits each
175+ component only after all components it depends on, so the natural output order
176+ is exactly the reverse-topological order the summary fixpoint wants: a callee's
177+ summary is final before any caller reads it; only same-SCC callees are still
178+ mid-iteration. Adjacency is sorted so the result (and the cap-free log) is
179+ deterministic regardless of input ordering."""
180+ index : dict [str , int ] = {}
181+ low : dict [str , int ] = {}
182+ on_stack : set [str ] = set ()
183+ stack : list [str ] = []
184+ out : list [list [str ]] = []
185+ counter = 0
186+ for root in adj :
187+ if root in index :
188+ continue
189+ index [root ] = low [root ] = counter
190+ counter += 1
191+ stack .append (root )
192+ on_stack .add (root )
193+ work : list [tuple [str , Iterator [str ]]] = [(root , iter (sorted (adj [root ])))]
194+ while work :
195+ node , it = work [- 1 ]
196+ descended = False
197+ for w in it : # the iterator is stored in `work`, so it resumes here
198+ if w not in index :
199+ index [w ] = low [w ] = counter
200+ counter += 1
201+ stack .append (w )
202+ on_stack .add (w )
203+ work .append ((w , iter (sorted (adj [w ]))))
204+ descended = True
205+ break
206+ if w in on_stack :
207+ low [node ] = min (low [node ], index [w ])
208+ if descended :
209+ continue
210+ if low [node ] == index [node ]: # component root
211+ comp : list [str ] = []
212+ while True :
213+ x = stack .pop ()
214+ on_stack .discard (x )
215+ comp .append (x )
216+ if x == node :
217+ break
218+ out .append (comp )
219+ work .pop ()
220+ if work : # propagate this node's low-link up to its DFS parent
221+ parent = work [- 1 ][0 ]
222+ low [parent ] = min (low [parent ], low [node ])
223+ return out
224+
225+
226+ def _call_graph (sk : dict [str , MethodSkeleton ]) -> dict [str , set [str ]]:
227+ """Dependency edges M -> callees whose summaries M's summary reads: every
228+ first-party callee a param forwards to, plus a forwarded return target."""
229+ adj : dict [str , set [str ]] = {k : set () for k in sk }
230+ for k , skel in sk .items ():
231+ deps = adj [k ]
232+ for p in skel .params :
233+ for a in p .paths :
234+ if a .kind == "forward" and a .callee in sk :
235+ deps .add (a .callee )
236+ if skel .ret .kind == "forward" and skel .ret .callee in sk :
237+ deps .add (skel .ret .callee )
238+ return adj
239+
240+
241+ def solve_with_log (skeletons : Iterable [MethodSkeleton ]) -> tuple [
242+ dict [str , MethodSummary ], list [str ]]:
243+ """Resolve every method's MOS by a summary fixpoint over the call graph's SCC
244+ condensation.
245+
246+ Returns (summaries-by-key, unresolved-log). The log names every forward that
247+ crosses an extern (unsummarized) boundary — the only place a transfer or return
248+ degrades to `unknown`. There is no depth cap and so no silent truncation: the
249+ condensation makes the work linear, and recursion is solved, not cut off."""
234250 sk : dict [str , MethodSkeleton ] = {}
235251 for s in skeletons :
236252 if s .key in sk :
@@ -239,24 +255,118 @@ def solve_with_log(skeletons: Iterable[MethodSkeleton], *,
239255 # depend on input order — fail fast instead.
240256 raise ValueError (f"duplicate MethodSkeleton key: { s .key } " )
241257 sk [s .key ] = s
242- capped : list [str ] = []
258+
259+ unresolved : set [str ] = set ()
260+ param_val : dict [ParamKey , Transfer ] = {} # finalized disposable-param transfers
261+
262+ def lookup (callee : str , arg : int , cur : dict [ParamKey , Transfer | None ]) -> Transfer | None :
263+ """The current transfer of callee param `arg` (resolve by `.index`): a final
264+ value for a callee in a lower SCC, the live iterate for one in the current
265+ SCC (possibly ⊥), `no` for a non-disposable, `unknown` past an extern edge."""
266+ skel = sk .get (callee )
267+ if skel is None :
268+ unresolved .add (f"{ callee } #{ arg } (extern, no summary)" )
269+ return Transfer .UNKNOWN
270+ p = next ((q for q in skel .params if q .index == arg ), None )
271+ if p is None :
272+ return Transfer .UNKNOWN # callee has no such logical param
273+ if not p .disposable :
274+ return Transfer .NO
275+ keyp = (callee , arg )
276+ if keyp in param_val :
277+ return param_val [keyp ]
278+ if keyp in cur :
279+ return cur [keyp ] # same-SCC member, mid-fixpoint (may be ⊥)
280+ return Transfer .UNKNOWN # unreachable under a correct topo order; fail closed
281+
282+ def contrib (a : PathAction , cur : dict [ParamKey , Transfer | None ]) -> Transfer | None :
283+ if a .kind in ("dispose" , "adopt" , "return" ):
284+ return Transfer .MUST # ownership left the caller on this path
285+ if a .kind == "borrow" :
286+ return Transfer .NO
287+ if a .kind == "forward" :
288+ return lookup (a .callee , a .arg , cur )
289+ return Transfer .UNKNOWN
290+
291+ def transfer_of (key : str , index : int ,
292+ cur : dict [ParamKey , Transfer | None ]) -> Transfer | None :
293+ p = next (q for q in sk [key ].params if q .index == index )
294+ if not p .paths :
295+ return Transfer .NO # nothing happens to it -> kept (borrowed)
296+ acc : Transfer | None = None
297+ for a in p .paths :
298+ acc = _join_opt (acc , contrib (a , cur ))
299+ return acc
300+
301+ # --- param transfers: bottom-up, per-SCC least fixpoint on the lattice -------
302+ for comp in _sccs (_call_graph (sk )):
303+ members : list [ParamKey ] = [
304+ (k , p .index ) for k in comp for p in sk [k ].params if p .disposable
305+ ]
306+ if not members :
307+ continue
308+ cur : dict [ParamKey , Transfer | None ] = dict .fromkeys (members ) # seed ⊥ (None)
309+ changed = True
310+ while changed : # monotone ascent on a height-3 lattice: converges fast
311+ changed = False
312+ for m in members :
313+ new = transfer_of (m [0 ], m [1 ], cur )
314+ if new != cur [m ]:
315+ cur [m ] = new
316+ changed = True
317+ for m in members : # ⊥ (no evidence) finalizes as `no` (kept/borrowed)
318+ v = cur [m ]
319+ param_val [m ] = v if v is not None else Transfer .NO
320+
321+ # --- returns: a memoized, cycle-safe chase along forward-return edges --------
322+ ret_val : dict [str , str ] = {}
323+
324+ def resolve_return (key : str , visiting : frozenset [str ]) -> str :
325+ if key in ret_val :
326+ return ret_val [key ] # context-insensitive: a return has one forward target
327+ r = sk [key ].ret
328+ if r .kind == "fresh" :
329+ v = "fresh"
330+ elif r .kind == "aliasOf" :
331+ v = f"aliasOf:{ r .arg } "
332+ elif r .kind == "aliased" :
333+ v = "aliased"
334+ elif r .kind == "none" :
335+ v = "none" # explicit no-owned-return
336+ elif r .kind == "forward" :
337+ if r .callee not in sk :
338+ unresolved .add (f"return { r .callee } (extern, no summary)" )
339+ v = "unknown"
340+ elif r .callee in visiting :
341+ v = "unknown" # return-forward cycle: no ground to stand on
342+ else :
343+ inner = resolve_return (r .callee , visiting | {key })
344+ # `inner` aliasOf:<i> aliases one of the *callee's* params; remapping it
345+ # to OUR args needs the call's argument mapping, which the skeleton does
346+ # not carry yet (the obligation-identity model, D5.4). Never propagate a
347+ # wrong index — degrade to unknown (precision-safe: nothing aliased at
348+ # lowering). fresh / aliased / none / unknown propagate as-is.
349+ v = "unknown" if inner .startswith ("aliasOf:" ) else inner
350+ else :
351+ v = "unknown" # an unrecognised kind fails closed, never silently "none"
352+ ret_val [key ] = v
353+ return v
354+
243355 out : dict [str , MethodSummary ] = {}
244356 for key , skel in sk .items ():
245357 params = tuple (
246358 ParamSummary (
247359 p .index , p .name , p .disposable ,
248- _resolve_param (key , p .index , 0 , frozenset ({key }), cap , sk , capped )
249- if p .disposable else Transfer .NO ,
360+ param_val .get ((key , p .index ), Transfer .NO ) if p .disposable else Transfer .NO ,
250361 p .escapes ,
251362 )
252363 for p in skel .params
253364 )
254- returns = _resolve_return (key , 0 , frozenset ({ key }), cap , sk , capped )
365+ returns = resolve_return (key , frozenset () )
255366 out [key ] = MethodSummary (key , params , returns , skel .file , skel .line )
256- return out , capped
367+ return out , sorted ( unresolved )
257368
258369
259- def solve (skeletons : Iterable [MethodSkeleton ], * ,
260- cap : int = DEFAULT_CAP ) -> dict [str , MethodSummary ]:
261- """Convenience wrapper around :func:`solve_with_log` dropping the cap log."""
262- return solve_with_log (skeletons , cap = cap )[0 ]
370+ def solve (skeletons : Iterable [MethodSkeleton ]) -> dict [str , MethodSummary ]:
371+ """Convenience wrapper around :func:`solve_with_log` dropping the unresolved log."""
372+ return solve_with_log (skeletons )[0 ]
0 commit comments