From 540e07dc60d9a51957a40f496583fcf72980eb44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:50:11 +0000 Subject: [PATCH 1/3] Interprocedural stage 2: per-overload signature keys in the MOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-name overloads no longer have to share one conservative summary. A functions[] record and a call op may both carry an optional 'sig' — the canonical parameter-type list (FQ names, comma-separated, no spaces, generic arity via backtick, global:: stripped). When an overloaded name's records carry it, _build_skeletons emits one summary per overload keyed name(sig) BESIDE the name-merged fallback, and every consumer resolves through one helper (_mos_lookup: sig key -> exact name -> canonical name): the overload channel, kill sites, OWN051 collection, the fresh-result mint, branch hoisting and the method's own param seeding — so the untrack set, the advisories and the emitted effects can never disagree. The fallback rule is load-bearing (tz §6.1): a sig missing or unmatched on either side of an edge lands on the merged summary — degraded, never a wrong overload — and the first_party/overloaded suppressions stay keyed on the bare name (INV4). Additive per the IR evolution policy: no OWNIR_VERSION bump; a non-string sig on a record fails load, on a flow op it reads as absent. Extractor: sig is stamped from the same resolved IMethodSymbol on both sides of the edge (CanonicalSig/CanonicalTypeName; type arguments erased to backtick arity — a collision merely merges, never mis-resolves), on the main flow record and the first-party-factory call op. Also fixes a latent parity-dump nondeterminism the new matrix exposed: _merge_skeletons took the first-seen param name per index, which depended on functions[] input order; it now takes the lexicographic min. Recall restored (e2e anchor OverloadSigSample.cs + CI assertions): a sig'd call to the fresh overload of a mixed factory leaks its dropped result (OWN001); a sig'd forward to a consume overload yields OWN002 — both were silenced by the merge. Test matrix from tz §6.1 lands in tests/test_ownir.py (14 new checks); solver untouched (keys stay opaque). Tier B review (DoD): Process.Start / new StreamReader(stream) stay excluded — their ambiguity is BCL-side (static-vs-instance, adopt); a sig-keyed Tier B table is now possible and recorded as follow-up. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U --- .github/workflows/ci.yml | 13 + docs/notes/interprocedural-roadmap.md | 17 ++ docs/notes/interprocedural-tz.md | 12 + frontend/roslyn/OwnSharp.Extractor/Program.cs | 68 ++++- frontend/roslyn/samples/OverloadSigSample.cs | 35 +++ ownlang/ownership.py | 11 +- ownlang/ownir.py | 287 +++++++++++++----- spec/OwnIR.md | 21 +- spec/ownir.schema.json | 7 +- tests/test_ownir.py | 225 ++++++++++++++ 10 files changed, 604 insertions(+), 92 deletions(-) create mode 100644 frontend/roslyn/samples/OverloadSigSample.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d4473f6..befba84a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,7 @@ jobs: frontend/roslyn/samples/SemaphoreFieldSample.cs \ frontend/roslyn/samples/VoidSubscribeSample.cs \ frontend/roslyn/samples/ReturnedPublisherSample.cs \ + frontend/roslyn/samples/OverloadSigSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - name: Check facts through the core @@ -256,6 +257,18 @@ jobs: echo "$out" | grep -qE "ReturnedPublisherSample\.cs:[0-9]+: warning: \[OWN001\].*'$ev'" \ || { echo "FAIL: expected the OWN001 warning to survive for '$ev' (provenance must deny)"; exit 1; } done + # Interprocedural stage 2 (spec/OwnIR.md §5.1): per-overload signature keys. + # SigOverloads.Open is overloaded — the (string) overload is a fresh factory, + # the (FileStream,bool) one returns its parameter — so the name-merged returns + # DISAGREE (no claim). The `sig` stamped on both the functions[] records and + # the call op resolves the fresh overload's own summary, so the dropped + # stream in Drop surfaces as OWN001 (severity-agnostic: a flow leak is an error). + grep -q '"sig":"System.String"\|"sig": "System.String"' "$RUNNER_TEMP/facts.json" \ + || { echo "FAIL: expected the (string) overload sig in the facts"; exit 1; } + grep -q '"sig":"System.IO.FileStream,System.Boolean"\|"sig": "System.IO.FileStream,System.Boolean"' "$RUNNER_TEMP/facts.json" \ + || { echo "FAIL: expected the (FileStream,bool) overload sig in the facts"; exit 1; } + echo "$out" | grep -qE "OverloadSigSample\.cs:[0-9]+:.*\[OWN001\]" \ + || { echo "FAIL: expected the sig-resolved fresh-overload leak (OWN001) in Drop"; exit 1; } # P-004 provenance: a local that ALIASES an injected source (var src = # _bus) is NOT method-bounded — it must warn, not be silently dropped. A # local the scope CONSTRUCTS (var owned = new Calc()) IS bounded -> silent. diff --git a/docs/notes/interprocedural-roadmap.md b/docs/notes/interprocedural-roadmap.md index 9a9bc490..754b7e0a 100644 --- a/docs/notes/interprocedural-roadmap.md +++ b/docs/notes/interprocedural-roadmap.md @@ -191,6 +191,23 @@ consume-сводку всех остальных, а overload-неоднозна одной арности с разными типами при варианте `arity` остаются слитыми — потому предпочтителен сразу `signature`, чтобы не делать двух миграций. +> **Статус: сделано (вариант `signature` сразу, минуя `arity`).** Формат и +> fallback-правила — `spec/OwnIR.md` §5.1 + `spec/ownir.schema.json`. Мост: +> `_build_skeletons` эмитит per-overload скелет `name(sig)` РЯДОМ со слитым +> по имени (fallback для рёбер без `sig`); `_mos_lookup` — единая резолюция +> (sig-ключ → точное имя → каноническое) для канала, kill-sites, OWN051, +> fresh-минта и параметров самого метода. Extractor штампует `sig` из одного +> и того же `IMethodSymbol` на `functions[]`-записи и `call`-опе (generic- +> аргументы стёрты до backtick-арности — коллизия просто сливает группу, +> никогда не «мимо»). Паритетный дамп несёт оба словаря ключей; e2e-якорь — +> `OverloadSigSample.cs` (fresh-перегрузка возвращает OWN001, который merge +> терял). Тест-матрица §6.1 ТЗ — в `tests/test_ownir.py` (stage 2 блок). +> Пересмотр Tier B-исключений: `Process.Start` / `new StreamReader(stream)` +> остаются вне таблицы — их неоднозначность лежит на BCL-стороне (static-vs- +> instance, adopt-vs-factory), где `functions[]`-записей нет и per-overload +> summary не возникает; sig-ключёванная Tier B-таблица теперь ВОЗМОЖНА +> (у `call`-опа есть `sig`), но это отдельный аддитивный срез. + ## 6. Этап 3 — дожать T1/T4: `aliasOf:i` через return, out/ref (2–3 недели) Самый большой прикладной выигрыш из оставшихся — «step 2 remainder» D5.4: diff --git a/docs/notes/interprocedural-tz.md b/docs/notes/interprocedural-tz.md index c8ac42a5..eb9515cd 100644 --- a/docs/notes/interprocedural-tz.md +++ b/docs/notes/interprocedural-tz.md @@ -429,6 +429,18 @@ Python-стороне — тот же ход, что уже сделан для перегрузок; `_merge_skeletons` вызывается только для реально одноключевых групп; паритетный дамп сводок расширен ключом. +> **Статус: сделано.** Спека — `spec/OwnIR.md` §5.1 (+ schema). Ключ и +> fallback — как выше; одно уточнение против буквы DoD: слитый-по-имени +> скелет НЕ исчез — он эмитится рядом с per-overload ключами как цель для +> рёбер без `sig` (иначе fallback-правилу не во что резолвиться), а +> `_merge_skeletons` для sig-групп вызывается только на реально одноключевых +> (обычно одиночных) группах. Тест-матрица этого раздела — полностью в +> `tests/test_ownir.py` (включая global::-qualified `sig`, смешанных +> продюсеров и fresh-регресс fallback'а). Tier B: пересмотрено, записано в +> roadmap §5 — существующие исключения остаются (неоднозначность BCL-стороны, +> не перегрузок первопартийного множества), sig-ключёванная таблица — +> возможный аддитивный follow-up. + ### 6.2 Этап 3 — `aliasOf:i` через return (первый срез) - **Деривация:** `ReturnSkeleton("aliasOf", i)` ⇔ ровно один возвращаемый var diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 24d64ddf..0114ec03 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1162,9 +1162,11 @@ oce.ArgumentList is { } args // `callee` matches the `functions[]` key `{TypeName}.{MethodName}`. A null/extern symbol, // a void/non-disposable return, or a dispose-optional return is rejected (no claim); an // overload (non-unique name) resolves to `unknown` in the core and is silently safe. -static bool IsFirstPartyDisposableFactory(ExpressionSyntax? expr, SemanticModel model, out string callee) +static bool IsFirstPartyDisposableFactory(ExpressionSyntax? expr, SemanticModel model, + out string callee, out string sig) { callee = ""; + sig = ""; if (expr is not InvocationExpressionSyntax inv) return false; if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol m) @@ -1176,8 +1178,10 @@ static bool IsFirstPartyDisposableFactory(ExpressionSyntax? expr, SemanticModel // Fully-qualified key (namespace + containing-type chain) so the call resolves to the // RIGHT summary: two `StreamFactory.Make` in different namespaces must not alias, or a // call to a non-fresh one could pick up a fresh one's summary and fabricate OWN001 - // (Codex). Must match the `functions[]` name built by `FlowFunctionName`. + // (Codex). Must match the `functions[]` name built by `FlowFunctionName`; the stage-2 + // `sig` narrows the same call to the resolved OVERLOAD's summary (spec §5.1). callee = $"{m.ContainingType.ToDisplayString()}.{m.Name}"; + sig = CanonicalSig(m); return true; } @@ -1191,6 +1195,33 @@ static string FlowFunctionName(BaseMethodDeclarationSyntax method, string fallba ? $"{ms.ContainingType.ToDisplayString()}.{ms.Name}" : $"{fallbackType}.{MethodName(method)}"; +// Interprocedural stage 2 (spec/OwnIR.md §5.1): the canonical per-overload signature key — +// the method's parameter TYPES, fully-qualified, comma-separated, no spaces, generic arity +// via backtick (type arguments erased, so two same-arity `List` overloads share a sig — +// a collision merely merges those overloads' summaries, never mis-resolves), `global::` +// stripped, "" for zero parameters. Both sides of an edge (the `functions[]` record and +// the `call` op) derive it from the SAME resolved IMethodSymbol, so they agree by +// construction; the bridge falls back to the name-merged summary whenever either side +// lacks or mismatches it. +static string CanonicalSig(IMethodSymbol m) => + string.Join(",", m.Parameters.Select(p => CanonicalTypeName(p.Type))); + +static string CanonicalTypeName(ITypeSymbol t) => t switch +{ + IArrayTypeSymbol a => CanonicalTypeName(a.ElementType) + + "[" + new string(',', a.Rank - 1) + "]", + IPointerTypeSymbol p => CanonicalTypeName(p.PointedAtType) + "*", + ITypeParameterSymbol tp => tp.Name, // erased: `Wrap(T x)` keys as "T" + // MetadataName carries the backtick arity (List`1) and drops nullable + // annotations (`string?` == `string` — C# overloads cannot differ there). + INamedTypeSymbol n when n.ContainingType is { } outer => + CanonicalTypeName(outer) + "+" + n.MetadataName, + INamedTypeSymbol n => n.ContainingNamespace is { IsGlobalNamespace: false } ns + ? $"{ns.ToDisplayString()}.{n.MetadataName}" + : n.MetadataName, + _ => t.ToDisplayString().Replace("global::", ""), // dynamic / error types +}; + // P-005 D5.4 (T4 wrap/adopt): the set of OWNING fields a first-party type disposes // UNCONDITIONALLY in its `Dispose()` — i.e. a `_f.Dispose()` / `_f?.Dispose()` / // `this._f.Dispose()` that is a TOP-LEVEL statement of the Dispose body (not nested in an @@ -1592,7 +1623,8 @@ or ImplicitObjectCreationExpressionSyntax // acquire); the core mints the acquire only if it proves the callee returns // `fresh`, so a non-fresh first-party call is never falsely owned. else if (tracked.Contains(v.Identifier.Text) - && IsFirstPartyDisposableFactory(v.Initializer?.Value, model, out var fpCallee)) + && IsFirstPartyDisposableFactory(v.Initializer?.Value, model, + out var fpCallee, out var fpSig)) { // Preserve the call's TRACKED identifier args (CodeRabbit) so the core // can apply the callee's per-argument ownership effects (consume/borrow) @@ -1611,7 +1643,8 @@ or ImplicitObjectCreationExpressionSyntax .Where(tracked.Contains) .ToArray() : Array.Empty(); - nodes.Add(new { op = "call", callee = fpCallee, args = fpArgs, + nodes.Add(new { op = "call", callee = fpCallee, sig = fpSig, + args = fpArgs, result = v.Identifier.Text, line = LineOf(v) }); } // POOL005: a full-length view in the initializer — `var copy = buf.AsSpan().ToArray();` @@ -4428,7 +4461,8 @@ or ImplicitObjectCreationExpressionSyntax } init candidates.Add(v.Identifier.Text); else if (IsMemoryPoolRent(v.Initializer?.Value, model)) // MemoryPool IMemoryOwner (Dispose-released, NOT a poolBuffer) candidates.Add(v.Identifier.Text); - else if (IsFirstPartyDisposableFactory(v.Initializer?.Value, model, out _)) + else if (IsFirstPartyDisposableFactory(v.Initializer?.Value, model, + out _, out _)) // P-005 D5.2: `var r = FirstPartyFactory()` — a candidate acquire // IFF the core proves the callee returns `fresh` (it emits a `call` // op, not an `acquire`; the core decides). Checked last so `new` / @@ -4568,12 +4602,24 @@ or ImplicitObjectCreationExpressionSyntax } init continue; } statMethodsAnalysed++; - flowFunctions.Add(new - { - name = FlowFunctionName(method, cls.Identifier.Text, model), - file, - body = fbody, - }); + // Stage 2 (spec §5.1): stamp the record's per-overload `sig` when the + // symbol resolves, so an overloaded method gets its own summary beside + // the name-merge; an unresolved symbol omits the field (the bridge then + // keeps the merged-fallback behaviour — degraded, never mis-keyed). + flowFunctions.Add(model.GetDeclaredSymbol(method) is IMethodSymbol msym + ? new + { + name = $"{msym.ContainingType.ToDisplayString()}.{msym.Name}", + file, + sig = CanonicalSig(msym), + body = fbody, + } + : (object)new + { + name = FlowFunctionName(method, cls.Identifier.Text, model), + file, + body = fbody, + }); } if (subs.Count > 0) diff --git a/frontend/roslyn/samples/OverloadSigSample.cs b/frontend/roslyn/samples/OverloadSigSample.cs new file mode 100644 index 00000000..27f69e17 --- /dev/null +++ b/frontend/roslyn/samples/OverloadSigSample.cs @@ -0,0 +1,35 @@ +using System.IO; + +// Interprocedural stage 2 (spec/OwnIR.md §5.1) — per-overload signature keys. +// +// `Open` is OVERLOADED: the `(string)` overload is a fresh factory (constructs +// and returns a new FileStream), the `(FileStream, bool)` overload returns its +// own parameter (not fresh). Pre-stage-2 both merged into one name-keyed summary +// whose returns DISAGREE -> no fresh claim -> the dropped result in `Drop` leaked +// invisibly. With `sig` stamped on the `functions[]` records and the `call` op, +// the call resolves the `(string)` overload's own summary (`fresh`), so the +// dropped stream surfaces as OWN001 at the call site. + +public static class SigOverloads +{ + public static FileStream Open(string path) + { + var made = new FileStream(path, FileMode.Open); // fresh factory + return made; + } + + public static FileStream Open(FileStream existing, bool flush) + { + var probe = new MemoryStream(); // a tracked local, so this overload emits a record + probe.Dispose(); + if (flush) + existing.Flush(); + return existing; // returns a PARAMETER -> not fresh + } + + public static void Drop(string path) + { + var s = Open(path); // sig'd call -> the fresh overload's contract -> dropped result + s.Flush(); // used but never disposed: OWN001 (the leak stage 2 restores) + } +} diff --git a/ownlang/ownership.py b/ownlang/ownership.py index b32239dd..9d9ff50a 100644 --- a/ownlang/ownership.py +++ b/ownlang/ownership.py @@ -121,10 +121,13 @@ class ReturnSkeleton: @dataclass(frozen=True) class MethodSkeleton: - key: str # method identity the call graph resolves on. NOT a full signature key: the - # extractor names a callee `{Type}.{Method}` with no parameter signature, so - # same-name overloads share a key and are merged conservatively before solving - # (`_merge_skeletons` in ownir.py — note's open question 2, partially addressed). + key: str # method identity the call graph resolves on — an opaque string to this + # solver. The bridge (ownir.py) keys a method `{Type}.{Method}` and, since + # interprocedural stage 2, ALSO emits one `{Type}.{Method}(sig)` skeleton per + # overload whose record carries a canonical parameter-type `sig`; a forward + # edge targets the per-overload key when its call op carries a matching `sig`, + # else the bare-name key (same-name overloads merged conservatively there — + # `_merge_skeletons`, the stage-2 fallback). params: tuple[ParamSkeleton, ...] = () ret: ReturnSkeleton = field(default_factory=ReturnSkeleton) file: str = "?" diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 12721f04..c996f25c 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -103,7 +103,8 @@ import json from collections import Counter -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, replace from typing import Any from .ast_nodes import ( @@ -639,6 +640,16 @@ def load(path: str) -> dict[str, Any]: if not isinstance(fns, list) or not all(isinstance(f, dict) for f in fns): raise OwnIRError("OwnIR 'functions' must be a JSON array of objects") for f in fns: + # Optional per-overload signature key (interprocedural stage 2): the + # canonical parameter-type list a `call` op's `sig` resolves against. + # Additive/optional — a producer without type information (ownts) omits + # it and the name-merged summary applies as before. Present-but-malformed + # is rejected here (fail loud); a malformed `sig` on a *flow op* is + # instead read as absent (`_call_sig`), which degrades to the merged + # summary — never a wrong overload. + fsig = f.get("sig") + if fsig is not None and not isinstance(fsig, str): + raise OwnIRError(f"function 'sig' must be a string, got {fsig!r}") # Optional ownership CONTRACT (P-006/2b): params + their effects. Additive # and optional — an older core just reads functions without contracts. An # omitted `effect` is INFERRED from the body (v1 contract inference), so the @@ -1048,7 +1059,7 @@ def to_module(facts: dict[str, Any], # to a may-position is not a gap worth a note. owned_here = _collect_vars(nodes, "acquire", "var") | { v for v, c in _call_result_callees(nodes).items() - if c and _callee_returns_fresh(c, mos, first_party)} + if c and _callee_returns_fresh(c[0], mos, first_party, c[1])} for arg, callee, transfer, cline in unverified: if arg not in owned_here: continue @@ -1153,11 +1164,11 @@ def walk(ns: Any) -> None: op = n.get("op") if op == "call": callee = str(n.get("callee", "")) - summ = mos.get(callee) if callee else None - if summ is None and callee: - identity = _canonical_callee_name(callee) - if identity != callee: - summ = mos.get(identity) + # per-overload key first, merged fallback second (stage 2) — the + # SAME resolution the `call` handler and the kill sites use, so + # the untrack set, the OWN051 advisories and the emitted channel + # effects can never disagree about which summary applied. + summ = _mos_lookup(mos, callee, _call_sig(n)) args = n.get("args", []) if summ is not None and isinstance(args, list): for j, a in enumerate(args): @@ -1229,11 +1240,8 @@ def collect_mints(n: Any) -> None: continue if n.get("op") == "call": callee = str(n.get("callee", "")) - summ = mos.get(callee) if callee else None - if summ is None and callee: - identity = _canonical_callee_name(callee) - if identity != callee: - summ = mos.get(identity) + # same stage-2 resolution as `_unverified_transfer_calls` — see there. + summ = _mos_lookup(mos, callee, _call_sig(n)) args = n.get("args", []) if summ is not None and isinstance(args, list): for j, a in enumerate(args): @@ -1311,12 +1319,15 @@ def _has_bare_return(nodes: Any) -> bool: return False -def _call_result_callees(nodes: Any) -> dict[str, str | None]: - """Map each `result` local of a `call` op to the callee that produced it (for - forward-return inference, P-005 D5.2). A local bound by two *different* callees - (e.g. on separate branches) maps to None — ambiguous, so never claimed as a - forward-return (precision-first).""" - out: dict[str, str | None] = {} +def _call_result_callees(nodes: Any) -> dict[str, tuple[str, str | None] | None]: + """Map each `result` local of a `call` op to `(callee, sig)` — the callee that + produced it plus the call's optional per-overload signature (for forward-return + inference, P-005 D5.2, and stage-2 fresh-factory resolution). A local bound by + two *different* callees (e.g. on separate branches) maps to None — ambiguous, + so never claimed as a forward-return; the same callee reached with two + different sigs keeps the callee but drops the sig (the name-merged fallback) — + degraded, never a wrong overload (precision-first).""" + out: dict[str, tuple[str, str | None] | None] = {} if not isinstance(nodes, list): return out @@ -1331,7 +1342,14 @@ def visit(ns: Any) -> None: res = n.get("result") callee = n.get("callee") if isinstance(res, str) and isinstance(callee, str) and callee: - out[res] = None if res in out and out[res] != callee else callee + entry: tuple[str, str | None] = (callee, _call_sig(n)) + prev = out.get(res) + if res not in out: + out[res] = entry + elif prev is None or prev[0] != callee: + out[res] = None + elif prev[1] != entry[1]: + out[res] = (callee, None) elif op == "if": visit(n.get("then")) visit(n.get("else")) @@ -1343,7 +1361,9 @@ def visit(ns: Any) -> None: def _infer_return_skeleton(nodes: Any, param_names: set[str], - first_party: frozenset[str] = frozenset()) -> ReturnSkeleton: + first_party: frozenset[str] = frozenset(), + call_key: Callable[[str, str | None], str] | None = None, + ) -> ReturnSkeleton: """Infer a method's owned-return kind for the D5.0 solver (P-005 D5.2, T1). `fresh` — every `return ` path returns a local the body itself `acquire`d @@ -1353,7 +1373,12 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str], is the result of a first-party `call` (a factory-of-factory: `var t = Make(); return t;`); the solver propagates `Make`'s own return kind. Anything else stays `none` (no claim) — precision-first: we only mark a call an acquire site when we - can prove the result is freshly owned.""" + can prove the result is freshly owned. + + `call_key` (interprocedural stage 2) maps a forward's `(callee, sig)` to the + summary key the solver resolves it against — the per-overload signature key + when one exists, else the bare name (the merged fallback). None keeps the + bare-name edge (hand-authored skeleton tests).""" returned = _collect_vars(nodes, "return", "var") if not returned: return ReturnSkeleton() # void / no value return @@ -1373,8 +1398,9 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str], return ReturnSkeleton("fresh") if len(returned) == 1: (v,) = tuple(returned) - callee = call_results.get(v) - if callee and v not in param_names and v not in acquired: + entry = call_results.get(v) + if entry and v not in param_names and v not in acquired: + callee, csig = entry if _canonical_callee_name(callee) not in first_party \ and _is_bcl_fresh_factory(callee): # a thin wrapper returning a BCL factory's result is itself `fresh` — the @@ -1385,7 +1411,8 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str], # own summary, fresh or not), do NOT apply the bare BCL table — `forward` so # the solver resolves the wrapper through that summary instead (Codex P2). return ReturnSkeleton("fresh") - return ReturnSkeleton("forward", callee=callee) + return ReturnSkeleton( + "forward", callee=call_key(callee, csig) if call_key else callee) return ReturnSkeleton() # not provably owned -> no claim @@ -1507,6 +1534,48 @@ def _canonical_callee_name(name: str) -> str: return name.removeprefix("global::") +def _sig_key(name: str, sig: str) -> str: + """The per-overload summary key (interprocedural stage 2): the method name plus + its canonical parameter-type list — `Ns.Type.M(System.IO.Stream,System.Int32)`. + A zero-parameter overload keys as `name()`, distinct from the bare name (which + stays the key of the name-MERGED fallback summary).""" + return f"{name}({sig})" + + +def _call_sig(node: dict[str, Any]) -> str | None: + """The optional `sig` field of a `functions[]` record or `call` op — the + canonical parameter-type list of the (resolved) method: fully-qualified names, + no spaces, generic arity via backtick, `global::` stripped. A missing or + non-string value reads as absent: the edge then resolves against the + name-merged summary (today's conservative join) — degraded, never a wrong + overload (interprocedural stage 2, tz §6.1 fallback rule).""" + sig = node.get("sig") + return sig if isinstance(sig, str) else None + + +def _mos_lookup(mos: dict[str, Any] | None, callee: str, + sig: str | None = None) -> Any | None: + """Resolve a call's MOS summary (interprocedural stage 2): the per-overload + signature key first (raw and `global::`-stripped), then the exact name, then + its canonical form — the name-merged fallback. A `sig` missing on either side + of the edge lands on the merged summary, never *past* a first-party callee; + the `first_party`/`overloaded` suppressions elsewhere stay keyed on the BARE + name regardless of `sig` (INV4).""" + if mos is None or not callee: + return None + identity = _canonical_callee_name(callee) + if sig is not None: + summ = mos.get(_sig_key(callee, sig)) + if summ is None and identity != callee: + summ = mos.get(_sig_key(identity, sig)) + if summ is not None: + return summ + summ = mos.get(callee) + if summ is None and identity != callee: + summ = mos.get(identity) + return summ + + def _is_bcl_fresh_factory(callee: str) -> bool: """True if `callee` names a curated BCL factory whose return the caller owns. Accepts ONLY the bare `Type.Method` (`File.OpenRead`, `SHA256.Create`) or its fully-qualified @@ -1521,7 +1590,8 @@ def _is_bcl_fresh_factory(callee: str) -> bool: def _callee_returns_fresh(callee: str, mos: dict[str, Any] | None, - first_party: frozenset[str] = frozenset()) -> bool: + first_party: frozenset[str] = frozenset(), + sig: str | None = None) -> bool: """Whether a `call` to `callee` yields a fresh owned result the caller must release. A first-party summary is AUTHORITATIVE — if one exists we trust its `returns`, so a same-named first-party `File.OpenRead` (Tier A) overrides the BCL table (Tier B) and is @@ -1535,13 +1605,16 @@ def _callee_returns_fresh(callee: str, mos: dict[str, Any] | None, would look fresh on a direct call while a wrapper around it (which `_infer_return_skeleton` already excludes) stays silent. Suppress the Tier B fallback for any first-party name so direct and wrapper-returned paths agree (CodeRabbit). Precision-safe: no summary + first-party - => no claim (a dropped overload we cannot prove owns its result).""" + => no claim (a dropped overload we cannot prove owns its result). + + `sig` (interprocedural stage 2) resolves a call to its per-overload summary + first, so a sig-carrying call to the fresh overload of a mixed factory is seen + as fresh; a sig-less call keeps the merged verdict (fresh only if ALL agree). + The `first_party` suppression stays on the bare name regardless (INV4).""" if not callee: return False identity = _canonical_callee_name(callee) - summ = mos.get(callee) if mos is not None else None - if summ is None and mos is not None and identity != callee: - summ = mos.get(identity) # a `global::`-qualified call resolves to its bare summary + summ = _mos_lookup(mos, callee, sig) if summ is not None: return getattr(summ, "returns", None) == "fresh" if identity in first_party: @@ -1634,13 +1707,14 @@ def _definite_release(pname: str, nodes: Any) -> bool: def _forward_targets(pname: str, nodes: Any, - recurse: bool = True) -> list[tuple[str, int]]: - """Every `(callee, arg_index)` a `call` op hands `pname` to. The argument + recurse: bool = True) -> list[tuple[str, str | None, int]]: + """Every `(callee, sig, arg_index)` a `call` op hands `pname` to. The argument *position* is the callee's parameter index — what `solve()` resolves against - the callee's summary (P-005 D5.1). With `recurse=False`, only top-level + the callee's summary (P-005 D5.1); `sig` is the call's optional per-overload + signature (interprocedural stage 2). With `recurse=False`, only top-level (straight-line) calls are counted, so a conditional/looped forward can be told apart from an unconditional one.""" - out: list[tuple[str, int]] = [] + out: list[tuple[str, str | None, int]] = [] if not isinstance(nodes, list): return out for n in nodes: @@ -1653,7 +1727,7 @@ def _forward_targets(pname: str, nodes: Any, if callee and isinstance(args, list): for j, a in enumerate(args): if str(a) == pname: - out.append((callee, j)) + out.append((callee, _call_sig(n), j)) elif op in ("if", "while") and recurse: subs = ([n.get("then"), n.get("else")] if op == "if" else [n.get("body")]) for sub in subs: @@ -1702,15 +1776,18 @@ def _early_return_before_forward(pname: str, nodes: Any) -> bool: return False -def _forward_path_action(callee: str, arg: int) -> PathAction: +def _forward_path_action(callee: str, sig: str | None, arg: int, + call_key: Callable[[str, str | None], str]) -> PathAction: """The skeleton path action for one forward edge. A forward to a fixed ownership-sink extern (D5.1b) is a *resolved* transfer recorded directly (`$consume` → `dispose`, `$borrow*` → `borrow`); any other callee is a - `forward` edge the solver resolves against that callee's summary.""" + `forward` edge the solver resolves against that callee's summary — + the per-overload signature key when the edge carries a `sig` that names an + emitted overload group, else the bare name (the merged fallback; stage 2).""" kind = _SINK_PATH_ACTION.get(callee) if kind is not None: return PathAction(kind) - return PathAction("forward", callee, arg) + return PathAction("forward", call_key(callee, sig), arg) def _merge_returns(rets: list[ReturnSkeleton]) -> ReturnSkeleton: @@ -1730,16 +1807,16 @@ def _merge_returns(rets: list[ReturnSkeleton]) -> ReturnSkeleton: def _merge_skeletons(group: list[MethodSkeleton]) -> MethodSkeleton: - """Collapse same-name overloads into ONE conservative summary. The extractor - names a call's callee `{Type}.{Method}` with no parameter signature (note's open - question 2), so a forward to an overloaded name cannot pick an overload. Rather - than drop them all (every such forward then stays `unknown`), join them on the - lattice at (name, parameter-index) granularity: a parameter transfers `must` only - when EVERY overload carrying that index consumes it, and an overload that merely + """Collapse same-key overloads into ONE conservative summary. A call op that + carries no `sig` names its callee `{Type}.{Method}` with no parameter + signature, so such a forward cannot pick an overload. Rather than drop them + all (every such forward then stays `unknown`), join them on the lattice at + (key, parameter-index) granularity: a parameter transfers `must` only when + EVERY overload carrying that index consumes it, and an overload that merely keeps an index contributes a `borrow` path so the join can never fabricate a - `must`. Disambiguating by argument type would need call-site type info the fact - stream does not carry; arity is available but not modelled here (the join is - already precision-safe, just coarser than a per-arity split would be).""" + `must`. Since interprocedural stage 2 this is the FALLBACK summary: a call op + whose `sig` names an emitted per-overload group resolves that overload's own + (unmerged) summary instead — see `_build_skeletons` / `_mos_lookup`.""" if len(group) == 1: return group[0] by_index: dict[int, list[PathAction]] = {} @@ -1749,7 +1826,10 @@ def _merge_skeletons(group: list[MethodSkeleton]) -> MethodSkeleton: # an overload that does nothing with this index KEEPS it (= `no`); record # that as a borrow path so it joins in rather than vanishing from concat. by_index.setdefault(p.index, []).extend(p.paths or (PathAction("borrow"),)) - names.setdefault(p.index, p.name) + # the merged param NAME must not depend on `functions[]` input order + # either (first-seen was order-dependent — latent until the stage-2 + # dump test permuted an overloaded name): take the lexicographic min. + names[p.index] = min(names.get(p.index, p.name), p.name) params = tuple( ParamSkeleton(i, names[i], True, tuple(by_index[i])) for i in sorted(by_index) ) @@ -1790,18 +1870,47 @@ def _build_skeletons(raw_fns: list[Any]) -> list[MethodSkeleton]: and returns it is `fresh` (a factory); `_infer_return_skeleton` keeps it precision-first (a returned parameter is never `fresh`). - Same-name overloads are MERGED into one conservative summary (`_merge_skeletons`) - rather than dropped: the call node names its callee without a parameter signature - (note's open question 2), so a forward to an overloaded name cannot pick an - overload, but a lattice join over the overloads still resolves it precision-safely - (and lets a uniformly-`fresh` overloaded factory be seen as fresh).""" + Same-name overloads (interprocedural stage 2): a name defined more than once + always yields the name-MERGED conservative summary (`_merge_skeletons`, keyed by + the bare name — the fallback every sig-less edge resolves against, exactly + today's behaviour), and ADDITIONALLY one summary per overload whose record + carries a `sig`, keyed `name(sig)` (`_sig_key`). A forward/return edge whose + call op carries a `sig` naming an emitted overload group targets that precise + key; any other edge targets the bare name — degraded to the merge, never *past* + a first-party callee (tz §6.1 fallback rule). A unique (non-overloaded) name + keeps its bare key even when its record carries a `sig` — there is nothing to + split, and the parity dump stays stable.""" counts = Counter(str(fn.get("name", "")) for fn in raw_fns if isinstance(fn, dict)) # every first-party method name (even overloaded ones): the BCL fresh-factory table # must NOT apply to a call whose target we compile from source — Tier A (the # first-party summary) authoritatively overrides Tier B (the curated BCL table) even # when the source method happens to share a BCL factory's name (Codex P2). first_party = frozenset(_canonical_callee_name(k) for k in counts if k) + # stage 2, pass 1: the per-overload keys that WILL be emitted, so pass 2 can + # target forward/return edges at them. Only an overloaded name's sig-carrying + # records mint a `name(sig)` key. + sig_keys = set() + for fn in raw_fns: + if not isinstance(fn, dict): + continue + name = str(fn.get("name", "")) + fsig = _call_sig(fn) + if name and counts[name] > 1 and fsig is not None: + sig_keys.add(_sig_key(name, fsig)) + + def call_key(callee: str, sig: str | None) -> str: + """The summary key a call-op edge resolves against: the per-overload key + when the edge's `sig` names an emitted group (raw or `global::`-stripped, + mirroring `_mos_lookup`), else the bare callee (merged fallback).""" + if sig is not None: + for cand in (_sig_key(callee, sig), + _sig_key(_canonical_callee_name(callee), sig)): + if cand in sig_keys: + return cand + return callee + by_key: dict[str, list[MethodSkeleton]] = {} + by_sig: dict[str, list[MethodSkeleton]] = {} for fn in raw_fns: if not isinstance(fn, dict): continue @@ -1840,7 +1949,8 @@ def _build_skeletons(raw_fns: list[Any]) -> list[MethodSkeleton]: elif passed: allt = _forward_targets(cname, body) top = _forward_targets(cname, body, recurse=False) - paths = tuple(_forward_path_action(c, j) for c, j in allt) + paths = tuple(_forward_path_action(c, s, j, call_key) + for c, s, j in allt) if not (len(allt) == 1 and len(top) == 1 and not _early_return_before_forward(cname, body)): # not a single unconditional handoff: a no-transfer path @@ -1854,14 +1964,23 @@ def _build_skeletons(raw_fns: list[Any]) -> list[MethodSkeleton]: paths = () params.append(ParamSkeleton(i, cname, True, paths)) pnames = {str(p.get("name", "")) for p in raw_params if isinstance(p, dict)} - ret = _infer_return_skeleton(body, pnames, first_party) + ret = _infer_return_skeleton(body, pnames, first_party, call_key) # carry the declaration file so the summary dump is navigable (functions[] # entries carry no line of their own — params do; line stays 0). - by_key.setdefault(key, []).append( - MethodSkeleton(key, tuple(params), ret, str(fn.get("file", "?")))) - # one skeleton per name: solve() keys by name (a forward names its callee with no - # signature), so same-name overloads are joined into a single conservative summary. - return [_merge_skeletons(group) for group in by_key.values()] + sk = MethodSkeleton(key, tuple(params), ret, str(fn.get("file", "?"))) + by_key.setdefault(key, []).append(sk) + fsig = _call_sig(fn) + if counts[key] > 1 and fsig is not None: + by_sig.setdefault(_sig_key(key, fsig), []).append(sk) + # one skeleton per bare name — same-name overloads joined into the conservative + # merged summary every sig-less edge resolves against (the stage-2 fallback) — + # plus one per emitted `name(sig)` overload group (usually a singleton; two + # records claiming the SAME name+sig still merge, so the solver never sees a + # duplicate key). Rekeying via `replace` keeps the record itself shared. + out = [_merge_skeletons(group) for group in by_key.values()] + out.extend(replace(_merge_skeletons(group), key=k) + for k, group in by_sig.items()) + return out def _infer_param_effect(pname: str, nodes: Any, @@ -1909,12 +2028,17 @@ def _lower_fn_params(fn: dict[str, Any], ffile: str, fname: str, the C# name. A `consume` parameter is an owned obligation in the callee — the same Param the `.own` front-end produces — so an undischarged one leaks (the obligation having moved in from the caller). A parameter with no explicit - `effect` has its contract INFERRED from the body (`_infer_param_effect`).""" + `effect` has its contract INFERRED from the body (`_infer_param_effect`). + + The method's own summary is resolved through its record's `sig` when present + (stage 2): an overload whose record carries a signature seeds its params from + its OWN per-overload summary, not the conservative name-merge — the contract + that actually matches the body being lowered.""" out: list[Param] = [] raw = fn.get("params", []) if not isinstance(raw, list): return out - summ = mos.get(fname) if mos is not None else None + summ = _mos_lookup(mos, fname, _call_sig(fn)) for i, p in enumerate(raw): if not isinstance(p, dict): continue @@ -1959,7 +2083,8 @@ def acquires(n: dict[str, Any]) -> bool: if n.get("op") == "acquire" and str(n.get("var", "")) == name: return True if n.get("op") == "call" and str(n.get("result", "")) == name: - return _callee_returns_fresh(str(n.get("callee", "")), mos, first_party) + return _callee_returns_fresh(str(n.get("callee", "")), mos, + first_party, _call_sig(n)) return False def analyze(seq: Any, acquired: bool) -> tuple[bool, bool]: @@ -2029,7 +2154,8 @@ def fresh_result(n: dict[str, Any]) -> str | None: callee, res = n.get("callee"), n.get("result") if not (isinstance(res, str) and res and isinstance(callee, str) and callee): return None - return res if _callee_returns_fresh(callee, mos, first_party) else None + return (res if _callee_returns_fresh(callee, mos, first_party, _call_sig(n)) + else None) def note_ref(name: str, depth: int) -> None: if name not in ref_depth or depth < ref_depth[name]: @@ -2222,21 +2348,26 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str, identity = _canonical_callee_name(callee) raw_args = n.get("args", []) summ = mos.get(callee) if (mos is not None and callee) else None - # The merged summary, resolving a `global::`-qualified call to its bare key (like - # `_callee_returns_fresh`). Used only by the overload channel below; the direct-`Call` - # path stays on the raw `summ` so it never names a callee absent from the core - # signature table (which would raise OWN040). - merged = summ if summ is not None else ( - mos.get(identity) if (mos is not None and identity != callee) else None) - if merged is not None and isinstance(raw_args, list) and ( + # The stage-2 resolution: the call's per-overload signature key first, + # then the name-merged summary (`global::`-resolved, like + # `_callee_returns_fresh`). Used only by the overload channel below; the + # direct-`Call` path stays on the raw `summ` so it never names a callee + # absent from the core signature table (which would raise OWN040). + resolved = _mos_lookup(mos, callee, _call_sig(n)) + if resolved is not None and isinstance(raw_args, list) and ( identity in overloaded or any(q.transfer in (Transfer.MAY, Transfer.UNKNOWN) - for q in merged.params)): + for q in resolved.params)): # Per-argument channel routing, for two shapes that must NOT emit a # direct `Call`: # - an OVERLOADED name: the core's last-wins signature table stores # one same-name FnDecl, so a direct Call would mis-apply one - # overload's effect — a false OWN002 when overloads disagree; + # overload's effect — a false OWN002 when overloads disagree. + # Routing is keyed on the BARE name regardless of `sig` (INV4), + # but the contract applied through the channel is the RESOLVED + # one: a sig-carrying call uses its own overload's precise + # summary (stage 2 — one borrow overload no longer dilutes the + # others' consume), a sig-less call the conservative merge; # - a callee with a MAY/UNKNOWN param: its arg is UNTRACKED (the # optimistic default + advisory OWN051), so a direct Call would # reference an undeclared name — a loud OWN030 (map-or-raise). @@ -2247,7 +2378,7 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str, # externs (never the callee name), so a qualified callee is safe # here. (Codex P2 / CodeRabbit.) for j, a in enumerate(raw_args): - ps = next((q for q in merged.params if q.index == j), None) + ps = next((q for q in resolved.params if q.index == j), None) channel = _CHANNEL_FOR_TRANSFER.get(ps.transfer) if ps else None if channel is not None and str(a) not in untracked: body.append(Call(channel, @@ -2292,7 +2423,8 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str, localmap.pop(result, None) if (isinstance(result, str) and result and result not in hoisted and result not in untracked - and _callee_returns_fresh(callee, mos, first_party)): + and _callee_returns_fresh(callee, mos, first_party, + _call_sig(n))): handle = f"loc_{loc[0]}" loc[0] += 1 localmap[result] = handle @@ -2381,7 +2513,12 @@ def dump_summaries(facts: dict[str, Any]) -> dict[str, Any]: order is fixed — the same facts yield byte-identical JSON regardless of `functions[]` input order. A failed solve degrades exactly like `check_facts` (empty summaries), with the reason in `degraded` — the same - honesty OWN052 gives the checking path.""" + honesty OWN052 gives the checking path. + + Interprocedural stage 2 extends the key vocabulary: an overloaded name whose + records carry `sig` appears BOTH as its bare-name merged fallback summary and + once per overload as `name(sig)` — the same keys `solve()` resolved edges + against, so the parity surface shows exactly what the checker used.""" raw_fns = facts.get("functions", []) raw_fns = raw_fns if isinstance(raw_fns, list) else [] degraded: str | None = None diff --git a/spec/OwnIR.md b/spec/OwnIR.md index b64d97a9..5775a19d 100644 --- a/spec/OwnIR.md +++ b/spec/OwnIR.md @@ -136,7 +136,7 @@ maps back to the exact C# local. The **complete** op vocabulary: | `overspan` | `var` | `Overspan` (POOL005: a full-length view of a pooled buffer) | | `return` | optional `var` | `Return` (ownership transfer out) | | `alias_join` | `var`, `src` | a new owning handle joined to `src`'s alias set (wrap/adopt, D5.4) | -| `call` | `callee`, `args`, optional `result` | a `Call` checked against the callee's contract; a `fresh`-returning callee mints an acquire for `result` (D5.2) | +| `call` | `callee`, `args`, optional `result`, optional `sig` | a `Call` checked against the callee's contract; a `fresh`-returning callee mints an acquire for `result` (D5.2) | | `if` | `then`, `else` (sub-bodies) | an `If` with both branches lowered | | `while` | `body` (sub-body) | a `While` — a back-edge the core's worklist fixpoint converges over (A1) | @@ -144,6 +144,25 @@ Anything else is a hard error (§2, fail-loud). Overwriting a tracked local (a re-bound `call` result or `alias_join` target) kills its previous ownership binding, so a lost prior obligation leaks rather than reading as clean. +### 5.1 Per-overload signature keys (`sig`, interprocedural stage 2) + +A `functions[]` record and a `call` op may both carry an **optional** `sig`: the +method's canonical parameter-type list — fully-qualified names, comma-separated, +no spaces, generic arity via backtick, `global::` stripped (e.g. +`"System.IO.Stream,System.Boolean"`; `""` for a zero-parameter overload). When an +**overloaded** name's records carry `sig`, the inference layer keys one summary +per overload as `name(sig)` *beside* the name-merged conservative summary, and a +`call`/forward edge whose `sig` matches resolves that overload's own contract — +one borrow overload no longer dilutes its siblings' consume/fresh verdicts. + +The fallback rule is load-bearing: a `sig` missing or unmatched on **either** +side of an edge resolves against the name-merged summary (the pre-stage-2 +behaviour) — degraded, never a wrong overload — and the `first_party` / +`overloaded` suppressions stay keyed on the **bare** name regardless of `sig` +(INV4). A producer without type information (ownts) simply omits the field. +Additive/optional per §2: no `OWNIR_VERSION` bump; a present-but-non-string +`sig` on a function record is rejected at load, on a flow op it reads as absent. + ## 6. DI registration graph (`services[]`) An optional array feeding the **DI001** captive-dependency check (P-006), a diff --git a/spec/ownir.schema.json b/spec/ownir.schema.json index 4a9b1ae5..128300c0 100644 --- a/spec/ownir.schema.json +++ b/spec/ownir.schema.json @@ -137,6 +137,10 @@ "properties": { "name": { "type": "string" }, "file": { "type": "string" }, + "sig": { + "description": "Optional per-overload signature key (interprocedural stage 2): the canonical parameter-type list — fully-qualified names, comma-separated, no spaces, generic arity via backtick, global:: stripped (e.g. \"System.IO.Stream,System.Boolean\"; \"\" for zero parameters). When an overloaded name's records carry it, each overload gets its own summary keyed name(sig) beside the name-merged fallback; a producer without type information omits it and the merged summary applies as before. Additive/optional — no OWNIR_VERSION bump.", + "type": "string" + }, "params": { "description": "The method's ownership contract: its parameters and their effects (P-006/2b). Optional — an omitted contract is inferred from the body.", "type": "array", @@ -227,11 +231,12 @@ }, { "title": "call", - "description": "A Call checked against the callee's contract; a fresh-returning callee mints an acquire for `result` (D5.2).", + "description": "A Call checked against the callee's contract; a fresh-returning callee mints an acquire for `result` (D5.2). An optional `sig` (the callee's canonical parameter-type list, same format as a function record's `sig`) resolves the call against that overload's own summary; absent or unmatched, the name-merged summary applies (stage 2 fallback — degraded, never a wrong overload).", "properties": { "op": { "const": "call" }, "line": { "type": "integer" }, "callee": { "type": "string" }, + "sig": { "type": "string" }, "args": { "type": "array", "items": { "type": "string" } }, "result": { "type": "string" } }, diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 7c46d09d..76cc5218 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -2003,6 +2003,231 @@ def _boom2(_sk): # type: ignore[no-untyped-def] if gotq != [("dQ", "OWN002")]: fails.append("§10 q2 qualified (global::) call to agreeing-consume overloads should " f"apply the merged consume (dQ OWN002), got {gotq}") + # --- interprocedural stage 2 (roadmap §5 / tz §6.1): the per-overload signature + # key. A `sig` on BOTH sides of an edge (the `functions[]` record and the + # `call` op) resolves that overload's own contract instead of the + # conservative name-merge; a `sig` missing/unmatched on EITHER side falls + # back to the merged summary — degraded, never a wrong overload. The + # `overloaded`/`first_party` suppressions stay keyed on the bare name (INV4). + _SIGC = "System.IO.Stream" # the consume overload + _SIGB = "System.IO.Stream,System.Boolean" # the borrow overload + _sig_fns = [ + {"name": "C.S", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "a", "line": 1}], + "body": [{"op": "release", "var": "a", "line": 2}]}, # consumes + {"name": "C.S", "file": "F.cs", "sig": _SIGB, + "params": [{"name": "b", "line": 5}, {"name": "flag", "line": 5}], + "body": [{"op": "use", "var": "b", "line": 6}]}, # borrows + ] + # (tz §6.1 matrix) sig'd DIRECT call to the CONSUME overload: the precise + # contract applies (must → $consume), so a later use is OWN002. Under the + # name-merge this was `may` (one OWN051, no verdict) — the recall stage 2 buys. + checks += 1 + s2c = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "d1", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "C.S", "args": ["s"], "sig": _SIGC, + "line": 11}, + {"op": "use", "var": "s", "line": 12}]}]}) + got2c = sorted((x.component, x.code) for x in s2c) + if got2c != [("d1", "OWN002")]: + fails.append("stage 2: sig'd call to the consume overload must apply consume " + f"(d1 OWN002), got {got2c}") + # sig'd DIRECT call to the BORROW overload: a lend, so the caller's own release + # balances the acquire — fully silent (no verdict, and no OWN051: the contract + # is verified, not `may`). + checks += 1 + s2b = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "d2", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "C.S", "args": ["s"], "sig": _SIGB, + "line": 11}, + {"op": "release", "var": "s", "line": 12}]}]}) + if s2b: + fails.append("stage 2: sig'd call to the borrow overload must be silent " + f"(verified lend + caller release), got " + f"{[(x.component, x.code) for x in s2b]}") + # (fallback, critical) a call WITHOUT `sig` into sig-carrying overloads keeps + # today's merged behaviour exactly: consume+borrow join to `may` → one OWN051 + # advisory, no verdict. + checks += 1 + s2f = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "d3", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "C.S", "args": ["s"], "line": 11}, + {"op": "release", "var": "s", "line": 12}]}]}) + got2f = [(x.component, x.code, x.advisory) for x in s2f] + if got2f != [("d3", "OWN051", True)]: + fails.append("stage 2: sig-less call must fall back to the merged `may` " + f"(d3 OWN051), got {got2f}") + # (fallback, critical) an UNMATCHED sig (no overload group with that key) also + # lands on the merge — never "past" the first-party callee, never a guess. + checks += 1 + s2u = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "d3u", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "C.S", "args": ["s"], + "sig": "System.String", "line": 11}, + {"op": "release", "var": "s", "line": 12}]}]}) + got2u = [(x.component, x.code, x.advisory) for x in s2u] + if got2u != [("d3u", "OWN051", True)]: + fails.append("stage 2: unmatched sig must fall back to the merged `may` " + f"(d3u OWN051), got {got2u}") + # a `global::`-qualified sig'd call resolves the same per-overload key as the + # bare spelling (the canonical-name half of `_mos_lookup`/`call_key`). + checks += 1 + s2q = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "d1q", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "global::C.S", "args": ["s"], + "sig": _SIGC, "line": 11}, + {"op": "use", "var": "s", "line": 12}]}]}) + got2q = sorted((x.component, x.code) for x in s2q) + if got2q != [("d1q", "OWN002")]: + fails.append("stage 2: global::-qualified sig'd call must resolve the " + f"consume overload (d1q OWN002), got {got2q}") + # FORWARD edges resolve by the same key: a helper whose body sig-forwards to + # the consume overload is itself a consumer (must), so the caller's later use + # is OWN002 — while a helper sig-forwarding to the borrow overload stays a + # verified lend (caller release balances; fully silent, no OWN051). + checks += 1 + s2fw = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "fwc", "file": "F.cs", "params": [{"name": "p", "line": 8}], + "body": [{"op": "call", "callee": "C.S", "args": ["p"], "sig": _SIGC, + "line": 9}]}, + {"name": "d4", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "fwc", "args": ["s"], "line": 11}, + {"op": "use", "var": "s", "line": 12}]}]}) + got2fw = sorted((x.component, x.code) for x in s2fw) + if got2fw != [("d4", "OWN002")]: + fails.append("stage 2: forward edge with sig must resolve `must` through " + f"the consume overload (d4 OWN002), got {got2fw}") + checks += 1 + s2fb = check_facts({"module": "M", "functions": [*_sig_fns, + {"name": "fwb", "file": "F.cs", "params": [{"name": "p", "line": 8}], + "body": [{"op": "call", "callee": "C.S", "args": ["p"], "sig": _SIGB, + "line": 9}]}, + {"name": "d5", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "fwb", "args": ["s"], "line": 11}, + {"op": "release", "var": "s", "line": 12}]}]}) + if s2fb: + fails.append("stage 2: forward edge with sig to the borrow overload must " + f"stay a silent lend, got " + f"{[(x.component, x.code) for x in s2fb]}") + # (tz §6.1 matrix) MIXED producers in one fact file: one record carries `sig`, + # its same-name sibling does not. The sig'd call still resolves its precise + # overload; the sig-less call gets the merge of BOTH records. + checks += 1 + s2m = check_facts({"module": "M", "functions": [ + {"name": "D.M", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "a", "line": 1}], + "body": [{"op": "release", "var": "a", "line": 2}]}, # consumes + {"name": "D.M", "file": "F.cs", + "params": [{"name": "b", "line": 5}], + "body": [{"op": "use", "var": "b", "line": 6}]}, # borrows, no sig + {"name": "d6", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "D.M", "args": ["s"], "sig": _SIGC, + "line": 11}, + {"op": "use", "var": "s", "line": 12}]}, + {"name": "d7", "file": "F.cs", + "body": [{"op": "acquire", "var": "t", "line": 20}, + {"op": "call", "callee": "D.M", "args": ["t"], "line": 21}, + {"op": "release", "var": "t", "line": 22}]}]}) + got2m = sorted((x.component, x.code) for x in s2m) + if got2m != [("d6", "OWN002"), ("d7", "OWN051")]: + fails.append("stage 2: mixed producers — sig'd call precise (d6 OWN002), " + f"sig-less call merged (d7 OWN051), got {got2m}") + # (tz §6.1 regression) a uniformly-fresh overloaded FACTORY stays fresh at the + # merged fallback even when its records now carry sigs — adding `sig` must not + # cost the fallback any of the merge's existing recall. + checks += 1 + s2rf = check_facts({"module": "M", "functions": [ + {"name": "C.F", "file": "F.cs", "sig": "", + "body": [{"op": "acquire", "var": "r", "line": 1}, + {"op": "return", "var": "r", "line": 2}]}, + {"name": "C.F", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "p", "line": 4}], + "body": [{"op": "acquire", "var": "r", "line": 5}, + {"op": "return", "var": "r", "line": 6}]}, + {"name": "fdrop2", "file": "F.cs", + "body": [{"op": "call", "callee": "C.F", "args": [], "result": "x", + "line": 10}]}]}) + got2rf = sorted((x.component, x.code) for x in s2rf) + if got2rf != [("fdrop2", "OWN001")]: + fails.append("stage 2: uniformly-fresh overloaded factory must stay fresh " + f"at the sig-less fallback (fdrop2 OWN001), got {got2rf}") + # a MIXED factory (one fresh overload, one not): the sig'd call to the fresh + # overload now leaks a dropped result (per-overload recall the merge lost); + # the sig'd call to the non-fresh overload and the sig-less call stay silent + # (merged returns disagree → no claim, exactly as before). + checks += 1 + s2mf = check_facts({"module": "M", "functions": [ + {"name": "C.H", "file": "F.cs", "sig": "", + "body": [{"op": "acquire", "var": "r", "line": 1}, + {"op": "return", "var": "r", "line": 2}]}, # fresh + {"name": "C.H", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "p", "line": 4}], + "body": [{"op": "use", "var": "p", "line": 5}]}, # not fresh + {"name": "d8", "file": "F.cs", + "body": [{"op": "call", "callee": "C.H", "args": [], "result": "x", + "sig": "", "line": 10}]}, + {"name": "d9", "file": "F.cs", + "body": [{"op": "call", "callee": "C.H", "args": [], "result": "y", + "line": 20}]}]}) + got2mf = sorted((x.component, x.code) for x in s2mf) + if got2mf != [("d8", "OWN001")]: + fails.append("stage 2: mixed factory — sig'd call to the fresh overload " + f"leaks a dropped result, others silent, got {got2mf}") + # two records claiming the SAME name+sig merge inside their overload group + # (the solver never sees a duplicate key — no degradation, no OWN052). + checks += 1 + s2dup = check_facts({"module": "M", "functions": [ + {"name": "D.D", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "a", "line": 1}], + "body": [{"op": "release", "var": "a", "line": 2}]}, + {"name": "D.D", "file": "F.cs", "sig": _SIGC, + "params": [{"name": "b", "line": 5}], + "body": [{"op": "release", "var": "b", "line": 6}]}, + {"name": "dd", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "D.D", "args": ["s"], "sig": _SIGC, + "line": 11}, + {"op": "use", "var": "s", "line": 12}]}]}) + got2dup = sorted((x.component, x.code) for x in s2dup) + if got2dup != [("dd", "OWN002")]: + fails.append("stage 2: duplicate name+sig records must merge within their " + f"group, not degrade the solve (dd OWN002), got {got2dup}") + # the parity dump (roadmap stage 1) carries the extended key vocabulary: the + # bare-name merged fallback AND one `name(sig)` entry per overload, each with + # its own (unmerged) verdicts — byte-deterministic under input permutation. + checks += 1 + _sig_dump_facts = {"module": "M", "functions": [dict(f) for f in _sig_fns]} + sdoc = dump_summaries(_sig_dump_facts) + skeys = [s["method"] for s in sdoc["summaries"]] + stransfers = {s["method"]: [p["transfer"] for p in s["params"]] + for s in sdoc["summaries"]} + if (skeys != ["C.S", f"C.S({_SIGC})", f"C.S({_SIGB})"] + or stransfers["C.S"] != ["may", "no"] + or stransfers[f"C.S({_SIGC})"] != ["must"] + or stransfers[f"C.S({_SIGB})"] != ["no", "no"]): + fails.append(f"stage 2: summaries dump key/verdict vocabulary wrong: {sdoc}") + checks += 1 + sblob1 = json.dumps(sdoc, indent=2, sort_keys=True) + _sig_dump_facts["functions"].reverse() + sblob2 = json.dumps(dump_summaries(_sig_dump_facts), indent=2, sort_keys=True) + if sblob1 != sblob2: + fails.append("stage 2: sig-keyed summaries dump must stay byte-identical " + "under functions[] input permutation") + # load() validates the record field's type (additive optional, never garbage); + # a malformed sig on a flow OP is read as absent (merged fallback), not a crash. + checks += 1 + if not _load_raises({"ownir_version": OWNIR_VERSION, "module": "M", + "components": [], + "functions": [{"name": "C.S", "sig": 5, "body": []}]}): + fails.append("non-string function 'sig' was accepted (should raise OwnIRError)") # --- P-005 D5.1b: the per-call-site ownership-contract channel. The extractor # routes a call's per-argument ownership through fixed sink externs # ($consume / $borrow / $borrow_mut) the bridge pre-declares, so an effect From 42561e7b1abf1947bc1a8364ceef8e4700fdc663 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:46:29 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20stage=202=20review=20round=20?= =?UTF-8?q?=E2=80=94=20flow-locals=20CI=20home,=20edge=20canonicalization,?= =?UTF-8?q?=20extension-method=20sigs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wpf-extractor CI job runs WITHOUT --flow-locals, so functions[] is always empty there and the stage-2 assertions could never pass (first CI run caught it). Move OverloadSigSample.cs and its assertions into the dedicated flow-locals step (flow.json), rename its locals (made->opened, s->dropped) clear of the step's existing silent-case guard, and pin the exact leak wording plus the opened/probe silent cases. Codex P2 x2, both verified real: - call_key now canonicalizes the sig-less/unmatched fallback: a forward or return edge to a global::-qualified first-party callee lands on the bare-name merged summary exactly like _mos_lookup resolves a direct call, instead of going extern (unknown) and downgrading a resolvable must/fresh to OWN051/silence. Regression test: qualified forward to agreeing-consume overloads yields OWN002 (271/271). - CanonicalSig canonicalizes through (ReducedFrom ?? m).OriginalDefinition so a reduced extension-method call site (receiver dropped from Parameters) and a constructed generic (type args substituted) produce the same string the declared functions[] record stamps — the precise per-overload contract applies instead of the merged fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U --- .github/workflows/ci.yml | 33 +++++++++++-------- frontend/roslyn/OwnSharp.Extractor/Program.cs | 9 ++++- frontend/roslyn/samples/OverloadSigSample.cs | 8 ++--- ownlang/ownir.py | 12 +++++-- tests/test_ownir.py | 22 +++++++++++++ 5 files changed, 63 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befba84a..c3379bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,7 +205,6 @@ jobs: frontend/roslyn/samples/SemaphoreFieldSample.cs \ frontend/roslyn/samples/VoidSubscribeSample.cs \ frontend/roslyn/samples/ReturnedPublisherSample.cs \ - frontend/roslyn/samples/OverloadSigSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - name: Check facts through the core @@ -257,18 +256,6 @@ jobs: echo "$out" | grep -qE "ReturnedPublisherSample\.cs:[0-9]+: warning: \[OWN001\].*'$ev'" \ || { echo "FAIL: expected the OWN001 warning to survive for '$ev' (provenance must deny)"; exit 1; } done - # Interprocedural stage 2 (spec/OwnIR.md §5.1): per-overload signature keys. - # SigOverloads.Open is overloaded — the (string) overload is a fresh factory, - # the (FileStream,bool) one returns its parameter — so the name-merged returns - # DISAGREE (no claim). The `sig` stamped on both the functions[] records and - # the call op resolves the fresh overload's own summary, so the dropped - # stream in Drop surfaces as OWN001 (severity-agnostic: a flow leak is an error). - grep -q '"sig":"System.String"\|"sig": "System.String"' "$RUNNER_TEMP/facts.json" \ - || { echo "FAIL: expected the (string) overload sig in the facts"; exit 1; } - grep -q '"sig":"System.IO.FileStream,System.Boolean"\|"sig": "System.IO.FileStream,System.Boolean"' "$RUNNER_TEMP/facts.json" \ - || { echo "FAIL: expected the (FileStream,bool) overload sig in the facts"; exit 1; } - echo "$out" | grep -qE "OverloadSigSample\.cs:[0-9]+:.*\[OWN001\]" \ - || { echo "FAIL: expected the sig-resolved fresh-overload leak (OWN001) in Drop"; exit 1; } # P-004 provenance: a local that ALIASES an injected source (var src = # _bus) is NOT method-bounded — it must warn, not be silently dropped. A # local the scope CONSTRUCTS (var owned = new Calc()) IS bounded -> silent. @@ -744,7 +731,8 @@ jobs: dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ frontend/roslyn/samples/FlowLocalsSample.cs \ frontend/roslyn/samples/MemoryOwnerEscapeSample.cs \ - frontend/roslyn/samples/FactoryLeakSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json" + frontend/roslyn/samples/FactoryLeakSample.cs \ + frontend/roslyn/samples/OverloadSigSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json" out=$(python -m ownlang ownir "$RUNNER_TEMP/flow.json" || true) echo "$out" echo "$out" | grep -q "OWN002" || { echo "FAIL: expected OWN002 (use-after-dispose)"; exit 1; } @@ -933,6 +921,23 @@ jobs: for ok in factoryOk made; do if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.2 silent case '$ok' was reported"; exit 1; fi done + # Interprocedural stage 2 (spec/OwnIR.md §5.1): per-overload signature keys. + # SigOverloads.Open is overloaded — the (string) overload is a fresh factory, + # the (FileStream,bool) one returns its parameter — so the name-merged returns + # DISAGREE (no fresh claim). The `sig` stamped on both the functions[] records + # and the call op resolves the fresh overload's OWN summary, so the dropped + # stream in Drop surfaces as OWN001 at the call — the recall the merge lost. + grep -q '"sig": "System.String"\|"sig":"System.String"' "$RUNNER_TEMP/flow.json" \ + || { echo "FAIL: expected the (string) overload sig in the facts"; exit 1; } + grep -q '"sig": "System.IO.FileStream,System.Boolean"\|"sig":"System.IO.FileStream,System.Boolean"' "$RUNNER_TEMP/flow.json" \ + || { echo "FAIL: expected the (FileStream,bool) overload sig in the facts"; exit 1; } + echo "$out" | grep -qE "OverloadSigSample\.cs:[0-9]+:.*\[OWN001\].*'dropped' is never disposed" \ + || { echo "FAIL: expected the sig-resolved fresh-overload leak (OWN001 on 'dropped')"; exit 1; } + # the factory itself (transfers out via return), the non-fresh overload's + # balanced probe, and the fresh local it returns all stay silent. + for ok in opened probe; do + if echo "$out" | grep -q "'$ok'"; then echo "FAIL: stage-2 silent case '$ok' was reported"; exit 1; fi + done echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, try/finally sequential, never-vs-every-path wording, dispose-optional exempt, beyond flat)" - name: Gallery C#-native bad/ok pairs (examples/gallery/cs/) run: | diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 0114ec03..04d52e5b 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1203,8 +1203,15 @@ static string FlowFunctionName(BaseMethodDeclarationSyntax method, string fallba // the `call` op) derive it from the SAME resolved IMethodSymbol, so they agree by // construction; the bridge falls back to the name-merged summary whenever either side // lacks or mismatches it. +// Canonicalize through the DECLARED definition (Codex P2 on #217): a call site +// resolves the REDUCED form of an extension method (no `this` receiver in +// Parameters) and/or a CONSTRUCTED generic (type args substituted), while the +// `functions[]` record is stamped from the declaration — `ReducedFrom` restores +// the receiver parameter, `OriginalDefinition` restores the open type params, so +// both sides of the edge always produce the same string. static string CanonicalSig(IMethodSymbol m) => - string.Join(",", m.Parameters.Select(p => CanonicalTypeName(p.Type))); + string.Join(",", (m.ReducedFrom ?? m).OriginalDefinition + .Parameters.Select(p => CanonicalTypeName(p.Type))); static string CanonicalTypeName(ITypeSymbol t) => t switch { diff --git a/frontend/roslyn/samples/OverloadSigSample.cs b/frontend/roslyn/samples/OverloadSigSample.cs index 27f69e17..b27be0a7 100644 --- a/frontend/roslyn/samples/OverloadSigSample.cs +++ b/frontend/roslyn/samples/OverloadSigSample.cs @@ -14,8 +14,8 @@ public static class SigOverloads { public static FileStream Open(string path) { - var made = new FileStream(path, FileMode.Open); // fresh factory - return made; + var opened = new FileStream(path, FileMode.Open); // fresh factory + return opened; } public static FileStream Open(FileStream existing, bool flush) @@ -29,7 +29,7 @@ public static FileStream Open(FileStream existing, bool flush) public static void Drop(string path) { - var s = Open(path); // sig'd call -> the fresh overload's contract -> dropped result - s.Flush(); // used but never disposed: OWN001 (the leak stage 2 restores) + var dropped = Open(path); // sig'd call -> the fresh overload's contract + dropped.Flush(); // used but never disposed: OWN001 (the leak stage 2 restores) } } diff --git a/ownlang/ownir.py b/ownlang/ownir.py index c996f25c..f0ba7cec 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -1900,13 +1900,21 @@ def _build_skeletons(raw_fns: list[Any]) -> list[MethodSkeleton]: def call_key(callee: str, sig: str | None) -> str: """The summary key a call-op edge resolves against: the per-overload key - when the edge's `sig` names an emitted group (raw or `global::`-stripped, - mirroring `_mos_lookup`), else the bare callee (merged fallback).""" + when the edge's `sig` names an emitted group (raw or `global::`-stripped), + else the bare callee — canonicalized to its `global::`-stripped form when + THAT names a first-party record, so a forward to a qualified callee lands + on the merged summary exactly like `_mos_lookup` resolves a direct call + (Codex P2 on #217: the raw-keyed edge went extern → `unknown`, downgrading + a resolvable `must`/`fresh` to OWN051/silence).""" if sig is not None: for cand in (_sig_key(callee, sig), _sig_key(_canonical_callee_name(callee), sig)): if cand in sig_keys: return cand + if callee not in counts: + identity = _canonical_callee_name(callee) + if identity in counts: + return identity return callee by_key: dict[str, list[MethodSkeleton]] = {} diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 76cc5218..b07d38fb 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -2200,6 +2200,28 @@ def _boom2(_sk): # type: ignore[no-untyped-def] if got2dup != [("dd", "OWN002")]: fails.append("stage 2: duplicate name+sig records must merge within their " f"group, not degrade the solve (dd OWN002), got {got2dup}") + # (Codex P2 on #217) a FORWARD to a `global::`-qualified first-party callee + # resolves through the bare-name merged key, exactly like `_mos_lookup` does + # for a direct call: both overloads consume, so the wrapper's param solves + # `must` and the caller's later use is OWN002 — the raw-keyed edge used to go + # extern (`unknown`), downgrading the verdict to an OWN051 advisory. + checks += 1 + s2gf = check_facts({"module": "M", "functions": [ + {"name": "C.M", "file": "F.cs", "params": [{"name": "a", "line": 1}], + "body": [{"op": "release", "var": "a", "line": 2}]}, + {"name": "C.M", "file": "F.cs", "params": [{"name": "b", "line": 5}], + "body": [{"op": "release", "var": "b", "line": 6}]}, + {"name": "gfw", "file": "F.cs", "params": [{"name": "p", "line": 8}], + "body": [{"op": "call", "callee": "global::C.M", "args": ["p"], + "line": 9}]}, + {"name": "d10", "file": "F.cs", + "body": [{"op": "acquire", "var": "s", "line": 10}, + {"op": "call", "callee": "gfw", "args": ["s"], "line": 11}, + {"op": "use", "var": "s", "line": 12}]}]}) + got2gf = sorted((x.component, x.code) for x in s2gf) + if got2gf != [("d10", "OWN002")]: + fails.append("stage 2: forward to a global::-qualified first-party callee " + f"must resolve the merged consume (d10 OWN002), got {got2gf}") # the parity dump (roadmap stage 1) carries the extended key vocabulary: the # bare-name merged fallback AND one `name(sig)` entry per overload, each with # its own (unmerged) verdicts — byte-deterministic under input permutation. From d82d7efd3aab475a193772b4408b2991f0dfd1ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:52:47 +0000 Subject: [PATCH 3/3] ci: pin the stage-2 edge structurally (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two flow.json greps (which either side of the edge could satisfy alone) with a structural check: the two SigOverloads.Open records must carry exactly the (string) and (FileStream,bool) sigs, and Drop's call op must carry callee/sig/result for the (string) overload — so a missing or unmatched CALL-side sig fails on its own, not only via the downstream OWN001 assertion. Dry-run locally against the extractor fact shape, positive and negative. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U --- .github/workflows/ci.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3379bdc..e672f4d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -927,10 +927,26 @@ jobs: # DISAGREE (no fresh claim). The `sig` stamped on both the functions[] records # and the call op resolves the fresh overload's OWN summary, so the dropped # stream in Drop surfaces as OWN001 at the call — the recall the merge lost. - grep -q '"sig": "System.String"\|"sig":"System.String"' "$RUNNER_TEMP/flow.json" \ - || { echo "FAIL: expected the (string) overload sig in the facts"; exit 1; } - grep -q '"sig": "System.IO.FileStream,System.Boolean"\|"sig":"System.IO.FileStream,System.Boolean"' "$RUNNER_TEMP/flow.json" \ - || { echo "FAIL: expected the (FileStream,bool) overload sig in the facts"; exit 1; } + # Structural check (CodeRabbit): pin BOTH sides of the edge exactly — the two + # overload records' sigs AND the Drop call op's callee/sig/result — so a + # missing or unmatched CALL-side sig fails here by itself, not only via the + # downstream finding. + python3 - "$RUNNER_TEMP/flow.json" <<'PY' + import json, sys + facts = json.load(open(sys.argv[1])) + fns = facts.get("functions", []) + sigs = sorted(str(f.get("sig")) for f in fns + if f.get("name") == "SigOverloads.Open") + assert sigs == ["System.IO.FileStream,System.Boolean", "System.String"], \ + f"overload record sigs wrong: {sigs}" + calls = [op for f in fns if f.get("name") == "SigOverloads.Drop" + for op in f.get("body", []) if op.get("op") == "call"] + assert any(c.get("callee") == "SigOverloads.Open" + and c.get("sig") == "System.String" + and c.get("result") == "dropped" for c in calls), \ + f"Drop call op missing the (string) overload sig: {calls}" + print("OK: stage-2 sig stamped on both sides of the Drop edge") + PY echo "$out" | grep -qE "OverloadSigSample\.cs:[0-9]+:.*\[OWN001\].*'dropped' is never disposed" \ || { echo "FAIL: expected the sig-resolved fresh-overload leak (OWN001 on 'dropped')"; exit 1; } # the factory itself (transfers out via return), the non-fresh overload's