Skip to content

feat(extractor): inter-procedural consume contract → use-after-handoff OWN002 (recall 8/10→9/11)#55

Merged
PhysShell merged 3 commits into
mainfrom
claude/consume-contract
Jun 21, 2026
Merged

feat(extractor): inter-procedural consume contract → use-after-handoff OWN002 (recall 8/10→9/11)#55
PhysShell merged 3 commits into
mainfrom
claude/consume-contract

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

Teaches the extractor to catch an inter-procedural use-after-handoff — corpus recall 8/10 → 9/11, the last arm of the handoff gad.

The extractor treated every argument-pass as an escape (untracked). So a stream handed to a consumer that owns it — Archive(s) where Archive(Stream source) disposes source — simply vanished, and a later touch of s was invisible. That's the use-after-handoff arm of ownership-handoff-consume, missed for that reason.

Mechanism — a call-site release (the pool-Return pattern): a call to a first-party consumer — a method whose own body disposes a by-value IDisposable parameter — is modelled as a release of the matching argument at the call site. A use of that argument after the call is then a use-after-release (OWN002), via the existing flow machinery. The argument is also exempted from the escape set so it stays tracked through the handoff.

  • ConsumeReleaseArg + DisposesLocal — resolve the call's first-party target, and if its body disposes a by-value IDisposable parameter, return the matching argument's local name.
  • The escape exemption is gated on the same ConsumeReleaseArg and on the bare Consume(s); statement form the flow pass actually lowers — so an argument is exempted iff it is also released (never a tracked-but-unreleased false leak), and only the arg bound to the disposed by-value param is exempted.

It is inter-procedural — the signal is the callee's own body — but there is no cross-call signature table, so a callee with no body (interface/abstract/extern) or that doesn't dispose the param yields nothing and the argument stays an ordinary escape. No dangling call op, no bridge crash.

Note — design pivot during review. The first commit modelled the handoff as a call op resolved against the callee's signature in the OwnIR bridge. CI + Codex (P1) + CodeRabbit caught that it crashes on a consumer whose body the flow pass skips (UnitOfWorkFlowSample's ConsumeUnitOfWork): the call op referenced an undeclared callee → unmapped OWN040 → internal facts error (plus arg/param arity and an over-broad exemption). The call-site-release approach above dissolves all of those by construction (no call op → no OWN040/OWN041; exemption tied to the same body-inspection as the release → parameter-precise, no false leak; no callee-name key → no namespace collision). Same verdicts, crash-proof.

The proof (benchmark, CI)

New fixture ownership-handoff-use (a pure use-after-handoff — no leak arm) is a miss before and a catch (OWN002) after: recall 8/10 → 9/11. ownership-handoff-consume now fires both arms (OWN001+OWN002). Specificity stays 0 FP, CI floor --min-recall 9.

Validated locally (no .NET SDK): the exact facts the extractor emits → check_factsOWN001/OWN002 (before) and silence (after), for both fixtures; case.own pins the logic (test_corpus 7/7); the bridge machinery is intact (test_ownir 88/88). The C# emission is validated by the corpus-benchmark + wpf-extractor CI jobs.

Files

  • frontend/roslyn/OwnSharp.Extractor/Program.csConsumeReleaseArg / DisposesLocal, the EmitFlowExpr release branch, the escape exemption.
  • corpus/real-world/ownership-handoff-use/ — the pure use-after-handoff fixture.
  • .github/workflows/ci.yml — floor --min-recall 9.
  • docs/notes/corpus-benchmark.md (the → 9/11 ratchet) + docs/proposals/P-012-bug-corpus-mining.md.

Remaining recall backlog (2): a cross-method use-after-dispose and an injected-source region-escape.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED

Summary by CodeRabbit

  • Tests

    • Added corpus cases, notes, and updated expected diagnostics for an inter-procedural ownership handoff pattern, including the OWN002 scenario.
  • Bug Fixes

    • Improved flow-local lowering so disposable by-value arguments that a callee disposes are treated as consumed at the call site, reducing incorrect use-after-handoff results.
  • Documentation

    • Refreshed corpus benchmark and proposal notes with updated recall/ratchet figures and clearer descriptions of the inter-procedural detection behavior.
  • Chores

    • Tightened the CI corpus benchmark recall gate to a higher minimum score.

…002 (recall 8/10 -> 9/11)

The extractor treated every argument-pass as an ESCAPE (untracked), so a stream
handed to a consumer that owns it (`Archive(s)`) vanished and a later touch of `s`
was invisible -- the use-after-handoff arm of ownership-handoff-consume was a miss.

The OwnIR bridge already models consume contracts end-to-end (params with an
inferred `effect`, a `call` op that moves ownership across the call, pinned by
tests/fixtures/ownir/handoff_contract.facts.json + test_ownir). This teaches the
extractor to emit those facts:

- ContractedCallee: a call whose first-party target owns a by-value IDisposable
  parameter carries an ownership contract. Passing a tracked local to it is a
  handoff, lowered to a `{op:"call", callee, args}` op (not an escape); the bridge
  moves ownership per the callee's inferred contract (a body that releases its
  param => consume), so a use after the move is OWN002. The cut is the SIGNATURE,
  not whole-program points-to -- like Rust's move.
- By-value IDisposable parameters are tracked as owned obligations and emitted in
  the function's `params`, so the consumer's body (use + Dispose of the param) lets
  the bridge infer the contract and discharge it (no false leak on the consumer).
- A resource local passed to a contracted call is exempted from the escape set.

New fixture ownership-handoff-use (a pure use-after-handoff, no leak arm) is a miss
before and a catch (OWN002) after: recall 8/10 -> 9/11. ownership-handoff-consume
now fires both arms (OWN001+OWN002). Specificity 11/11, 0 FP, CI floor --min-recall 9.

Precision: the consumer shape (a by-value IDisposable param) appears nowhere else in
the corpus or samples, so the machinery engages only on the two handoff fixtures --
no other after.cs or dog-food scan can newly fire, and a `call` op is emitted only
for callees the extractor also lowers as functions (callee name == function name, so
no OWN040). The bridge half was validated locally (hand-built facts -> check_facts ->
the exact OWN001/OWN002 verdicts); case.own pins the logic (test_corpus 7/7,
test_ownir 88/88). The C# emission is validated by the corpus-benchmark + wpf-extractor
CI jobs (no local .NET SDK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc9cf4a0-ae54-4ee3-8e0c-fee4e13c5d47

📥 Commits

Reviewing files that changed from the base of the PR and between a25625f and da85701.

📒 Files selected for processing (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

📝 Walkthrough

Walkthrough

The PR extends the Roslyn --flow-locals extractor to detect inter-procedural ownership handoff via consume contracts. New helpers identify first-party methods with by-value IDisposable parameters that dispose their argument; EmitFlowExpr emits a release at call sites matching this pattern; escape tracking is refined to keep consumed arguments tracked for correct later analysis. A new corpus fixture demonstrates the resulting OWN002 detection, and benchmark docs and CI gating are updated from 8/10 to 9/11.

Changes

OWN002 Inter-procedural Ownership-Handoff Detection

Layer / File(s) Summary
Corpus fixture: ownership-handoff-use before/after/case/diagnostics/notes
corpus/real-world/ownership-handoff-use/before.cs, corpus/real-world/ownership-handoff-use/after.cs, corpus/real-world/ownership-handoff-use/case.own, corpus/real-world/ownership-handoff-use/expected-diagnostics.txt, corpus/real-world/ownership-handoff-use/notes.md
Establishes the pattern: before.cs contains the deliberate use-after-handoff bug where a caller passes ownership to a consumer and then accesses the disposed stream; after.cs shows the corrected pattern; case.own encodes the OWN002 violation in OwnLang; expected-diagnostics.txt declares OWN002; notes.md documents the inter-procedural consume-contract mechanism.
Extractor: consume-pattern detection and flow lowering
frontend/roslyn/OwnSharp.Extractor/Program.cs
ConsumeReleaseArgs and DisposesLocal helpers inspect invocation targets to identify first-party callees that dispose their by-value IDisposable parameter. EmitFlowExpr uses this detection to emit a release at the call site instead of treating the argument as a use. Escape classification in --flow-locals is updated to exclude consumed arguments from being marked escaped, preserving tracking for correct later use/release reasoning.
Benchmark docs and CI recall gate
docs/notes/corpus-benchmark.md, docs/proposals/P-012-bug-corpus-mining.md, .github/workflows/ci.yml
corpus-benchmark.md updates the ratchet heading to 9/11 and replaces the prior gap description with the consume-contract detection narrative. P-012-bug-corpus-mining.md reflects 9/11 clean / 0 FP over five steps and revised remaining-gaps list. The CI --min-recall gate is bumped from 8 to 9.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Both PRs extend the --flow-locals path in Program.cs to track IDisposable ownership and lowering behavior.
  • PhysShell/Own.NET#20: Modifies EmitFlowExpr disposal-release recognition to detect DisposeAsync alongside other disposal patterns that this PR refines via consume-contract modeling.
  • PhysShell/Own.NET#50: Introduced the corpus-benchmark CI job and the scripts/benchmark.py --min-recall gating that this PR tightens from 8 to 9.

Poem

🐰 A stream was passed along with care,
consume'd downstream beyond repair —
but callers tried to peek once more,
OWN002 guarded at the door.
Now nine of eleven ratchets gleam,
the bunny checks each ownership seam! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: inter-procedural consume contract implementation enabling detection of use-after-handoff violations (OWN002), with the accompanying recall improvement from 8/10 to 9/11.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/consume-contract

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 89fee9eff4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
if (e is not InvocationExpressionSyntax inv
|| model.GetSymbolInfo(inv).Symbol is not IMethodSymbol sym
|| sym.DeclaringSyntaxReferences.Length == 0) // first-party (declared in source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid contracting methods without lowered bodies

When the resolved target is a source-declared interface, abstract, partial-without-body, or extern method with an IDisposable parameter, this check returns a callee name even though the flow pass only emits functions for methods with method.Body. A call such as consumer.Consume(s) against a scanned interface therefore lowers to a call with no matching signature; the OwnIR bridge reports an unmapped OWN040 and the CLI exits with an internal facts error instead of treating the argument as an escape or skipping the contract.

Useful? React with 👍 / 👎.

Comment on lines +458 to +461
var callArgs = callInv.ArgumentList.Arguments
.Select(a => a.Expression is IdentifierNameSyntax aid
? aid.Identifier.Text : a.Expression.ToString())
.ToList();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match call args to emitted resource params

The callee signature emitted below contains only resourceParams, but this serializes every C# argument. For a valid consumer like Consume(int mode, Stream s), the facts contain two call args while Consume has one OwnIR parameter, so the core raises/skips OWN041 and never applies the consume effect; the handoff is then lost, causing the new use-after-handoff case to be missed or reported as a leak whenever the consuming method has any non-disposable parameter.

Useful? React with 👍 / 👎.

Comment on lines +1398 to +1401
bool consumedArg = idn.Parent is ArgumentSyntax
&& idn.Parent.Parent is ArgumentListSyntax
&& idn.Parent.Parent.Parent is InvocationExpressionSyntax cinv
&& ContractedCallee(cinv, model) is not null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only exempt calls that are actually lowered

This exemption applies to any contracted invocation argument, but LowerFlowStmt emits a call only when the whole statement is an InvocationExpressionSyntax. If the handoff is inside var n = Consume(s) or return Consume(s), the local is no longer treated as escaped here, yet no call op is generated to move ownership, so the analyzer can miss the use-after-handoff or produce a spurious leak for resources that were actually handed off.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 1398-1405: The consumedArg check in the contracted invocation
exemption is too broad and doesn't verify that the specific argument is bound to
a by-value IDisposable parameter. Modify the consumedArg assignment to not only
check if the argument is inside a contracted invocation (using
ContractedCallee), but also verify that the specific argument is actually bound
to a by-value IDisposable parameter of that contracted method. This requires
examining the parameter binding for the argument (idn) to ensure it corresponds
to a by-value IDisposable parameter before exempting it from escape rules.
- Around line 822-823: The key generation for ContractedCallee in the method
containing the return statement at line 822-823 and the function key generation
at lines 1423-1425 both lack namespace qualification, causing collisions for
methods with identical names and containing types across different namespaces.
Include the namespace information from the symbol's containing namespace in both
locations to fully qualify the generated keys. This ensures that the
ContractedCallee embedded in the call op at line 456 can reliably match to
function definitions and maintain correct inter-procedural contracts for
overloaded or same-named methods across different namespaces.
- Around line 455-463: The argument extraction logic in the Select statement
(lines 459-461) only preserves bare IdentifierNameSyntax and converts all other
expressions to string via ToString(), which loses the underlying identifier for
wrapped expressions like parentheses, casts, and null-forgiving operators.
Create a helper method that recursively unwraps expressions to extract the
underlying identifier name: for ParenthesizedExpressionSyntax unwrap the inner
expression, for CastExpressionSyntax extract the operand identifier, and for
PostfixUnaryExpressionSyntax with null-forgiving operator extract the operand
identifier. Update the Select statement to use this helper method instead of the
current conditional, falling back to ToString() only when no identifier can be
found after unwrapping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bec7af97-b5e2-47d8-b0b6-4e9e38cb1dd0

📥 Commits

Reviewing files that changed from the base of the PR and between c90a631 and 89fee9e.

📒 Files selected for processing (9)
  • .github/workflows/ci.yml
  • corpus/real-world/ownership-handoff-use/after.cs
  • corpus/real-world/ownership-handoff-use/before.cs
  • corpus/real-world/ownership-handoff-use/case.own
  • corpus/real-world/ownership-handoff-use/expected-diagnostics.txt
  • corpus/real-world/ownership-handoff-use/notes.md
  • docs/notes/corpus-benchmark.md
  • docs/proposals/P-012-bug-corpus-mining.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

Comment on lines +455 to +463
if (es.Expression is InvocationExpressionSyntax callInv
&& ContractedCallee(callInv, model) is { } callee)
{
var callArgs = callInv.ArgumentList.Arguments
.Select(a => a.Expression is IdentifierNameSyntax aid
? aid.Identifier.Text : a.Expression.ToString())
.ToList();
nodes.Add(new { op = "call", callee, args = callArgs, line = LineOf(callInv) });
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -i "Program.cs" | grep -i "OwnSharp.Extractor"

Repository: PhysShell/Own.NET

Length of output: 107


🏁 Script executed:

wc -l frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 112


🏁 Script executed:

sed -n '450,470p' frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1280


🏁 Script executed:

# Check for imports related to semantic model and symbols
head -100 frontend/roslyn/OwnSharp.Extractor/Program.cs | grep -E "using|Microsoft.CodeAnalysis"

Repository: PhysShell/Own.NET

Length of output: 196


🏁 Script executed:

# Look for the method containing these lines to understand context better
sed -n '440,480p' frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 2562


🏁 Script executed:

# Look at the method signature that contains this code
sed -n '400,445p' frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 2605


🏁 Script executed:

# Check if ContractedCallee or similar methods use model for symbol resolution
rg "ContractedCallee|GetSymbolInfo" frontend/roslyn/OwnSharp.Extractor/Program.cs -A 3 -B 1

Repository: PhysShell/Own.NET

Length of output: 3625


🏁 Script executed:

# Check what symbol types are available (ILocalSymbol, IParameterSymbol)
rg "ILocalSymbol|IParameterSymbol|ISymbol" frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 181


🏁 Script executed:

# Check if ParenthesizedExpressionSyntax, CastExpressionSyntax are used elsewhere
rg "ParenthesizedExpressionSyntax|CastExpressionSyntax|SuppressNullableWarningExpression" frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 109


🏁 Script executed:

# Check if IParameterSymbol is used in the codebase
rg "IParameterSymbol" frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Look for patterns of unwrapping expressions (e.g., stripping casts, parentheses)
rg -A 5 "while.*true|switch.*case" frontend/roslyn/OwnSharp.Extractor/Program.cs | head -80

Repository: PhysShell/Own.NET

Length of output: 571


🏁 Script executed:

# Check if the issue is real: look for actual test cases or comments about parenthesized/cast arguments
rg -i "parenthes|cast|suppress.*null|argument" frontend/roslyn/OwnSharp.Extractor/Program.cs | head -20

Repository: PhysShell/Own.NET

Length of output: 1436


🏁 Script executed:

# Verify the current behavior: does a.Expression.ToString() lose symbol info for complex expressions?
# Also check if there are any existing tests or examples of this problem
find frontend -name "*.cs" -type f | xargs grep -l "OWN002\|ownership.*transfer" | head -5

Repository: PhysShell/Own.NET

Length of output: 151


🏁 Script executed:

# Check what Microsoft.CodeAnalysis symbol types are imported/available
# IParameterSymbol should be in Microsoft.CodeAnalysis namespace
rg "namespace Microsoft.CodeAnalysis" --type cs | head -5

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Let's look at a full example of how GetSymbolInfo().Symbol is used to extract names
sed -n '200,250p' frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 2922


🏁 Script executed:

# Check if there are any tests that demonstrate the issue (call args with parentheses/casts/null-forgiving)
find . -name "*test*" -o -name "*Test*" | grep -i own | head -10

Repository: PhysShell/Own.NET

Length of output: 83


🏁 Script executed:

# Verify the proposed fix would compile by checking if all required syntax types exist
rg "PostfixUnaryExpressionSyntax\|SyntaxKind.SuppressNullableWarningExpression" --type cs

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Check the exact C# syntax namespace to verify PostfixUnaryExpressionSyntax and SuppressNullableWarningExpression exist
rg "PostfixUnaryExpressionSyntax" --type cs -c

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Let's verify what happens with ToString() on various expression types
# Check if there's any test or existing usage that shows the problem
sed -n '455,475p' frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1207


🏁 Script executed:

# Let's check if IParameterSymbol can be imported from Microsoft.CodeAnalysis
# by looking at the imports and the actual types used
head -20 frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1217


🏁 Script executed:

# Verify the semantic correctness: does the proposed FlowArgName method use valid types?
# Check if CastExpressionSyntax and ParenthesizedExpressionSyntax are already used
rg "CastExpressionSyntax|ParenthesizedExpressionSyntax" frontend/roslyn/OwnSharp.Extractor/Program.cs -c

Repository: PhysShell/Own.NET

Length of output: 63


🌐 Web query:

PostfixUnaryExpressionSyntax SuppressNullableWarningExpression C# Roslyn API

💡 Result:

In the.NET Compiler Platform (Roslyn) API, the null-forgiving (or suppression) operator (!) used in C# is represented in the syntax tree as a PostfixUnaryExpressionSyntax node [1][2][3]. Specifically, this node has a SyntaxKind of SuppressNullableWarningExpression [4]. Key details regarding this API representation include: 1. Syntax Node Structure: The PostfixUnaryExpressionSyntax class is a subclass of ExpressionSyntax [1]. When the C# compiler encounters the ! operator (e.g., variable!), it parses it as a PostfixUnaryExpressionSyntax node where the OperatorToken is the ! token [1]. 2. SyntaxKind Distinction: While other postfix operators (like post-increment x++ or post-decrement x--) are also represented by the PostfixUnaryExpressionSyntax class, the compiler distinguishes the null-forgiving operator by its SyntaxKind, which is defined as SuppressNullableWarningExpression in the Microsoft.CodeAnalysis.CSharp.SyntaxKind enumeration [5][4]. 3. Usage in Analysis: When building Roslyn analyzers or refactorings, you can identify these expressions by checking the Kind of the syntax node [6][4]. For example, in Roslyn's own source code, utility methods often identify this operator by checking if the node is a PostfixUnaryExpressionSyntax with the specific SuppressNullableWarningExpression kind [7]. 4. Compiler Context: This syntax node represents the developer's intent to suppress nullable reference type warnings for that specific expression [2][3][8]. The compiler uses this information during semantic analysis to avoid reporting diagnostic warnings that would otherwise occur if the expression were considered potentially null [2][8]. Because it is a purely syntactic construct for static analysis, it does not change the emitted IL code [9].

Citations:


🏁 Script executed:

# Check the actual Microsoft.CodeAnalysis version in use (from project file)
find . -name "*.csproj" | xargs grep -h "Microsoft.CodeAnalysis" | head -10

Repository: PhysShell/Own.NET

Length of output: 142


🏁 Script executed:

# Verify whether the suggested fix's approach (unwrapping syntax) is sound
# Let's trace how downstream uses the callArgs (the "call" op)
rg '"call"' frontend/roslyn/OwnSharp.Extractor/Program.cs -B 5 -A 5

Repository: PhysShell/Own.NET

Length of output: 580


🏁 Script executed:

# Check the Python side to see how it processes args from "call" ops
find . -name "*.py" -type f | xargs grep -l "call.*args\|ownership.*transfer" 2>/dev/null | head -5

Repository: PhysShell/Own.NET

Length of output: 161


🏁 Script executed:

# Check how the "call" ops are processed downstream in the Python code
cat ownlang/ownir.py | head -100

Repository: PhysShell/Own.NET

Length of output: 5424


🏁 Script executed:

# Look for how args are resolved in the Python processing
rg "call.*args|symbol.*lookup" ownlang/ --type py -A 3 -B 1

Repository: PhysShell/Own.NET

Length of output: 2309


🏁 Script executed:

# Verify the issue: check if args field is used for symbol resolution
rg '"args"' ownlang/ --type py -A 2 -B 2

Repository: PhysShell/Own.NET

Length of output: 717


🏁 Script executed:

# Verify the localmap lookup mechanism to confirm args must be exact variable names
grep -A 10 "localmap" ownlang/ownir.py | head -20

Repository: PhysShell/Own.NET

Length of output: 1368


🏁 Script executed:

# Double-check the current code behavior with a concrete example
# If we have `foo((x))` or `foo((int)x)`, what happens with ToString()?
python3 - <<'PY'
# Simulate what happens with current code
expr_text = "(x)"
expr_tostring = "(x)"  # ToString() on parenthesized expression includes parens

localmap = {"x": "x_local"}  # Actual local name

# Current code behavior:
arg_value = expr_tostring
resolved = localmap.get(arg_value, arg_value)
print(f"Expression: {expr_text}")
print(f"Current code resolves to: {resolved}")
print(f"Expected (local name 'x'): {localmap.get('x', 'x')}")
print(f"Match: {resolved == 'x_local'}")
PY

Repository: PhysShell/Own.NET

Length of output: 155


Preserve symbol-backed argument identity when lowering call ops.

Lines 459–461 only keep bare identifiers; parenthesized, cast, and null-forgiving forms become raw text via ToString(). Downstream (ownlang/ownir.py) resolves args via localmap.get(str(a)), so wrapper expressions lose the underlying variable name, breaking symbol-based ownership tracking and causing missed OWN002 checks or spurious caller leaks.

Suggested fix
+static string? FlowArgName(ExpressionSyntax expr, SemanticModel model)
+{
+    ExpressionSyntax e = expr;
+    while (true)
+    {
+        switch (e)
+        {
+            case ParenthesizedExpressionSyntax p:
+                e = p.Expression;
+                continue;
+            case CastExpressionSyntax c:
+                e = c.Expression;
+                continue;
+            case PostfixUnaryExpressionSyntax u
+                when u.IsKind(SyntaxKind.SuppressNullableWarningExpression):
+                e = u.Operand;
+                continue;
+        }
+        break;
+    }
+    return model.GetSymbolInfo(e).Symbol switch
+    {
+        ILocalSymbol l => l.Name,
+        IParameterSymbol p => p.Name,
+        _ => null,
+    };
+}
...
-                var callArgs = callInv.ArgumentList.Arguments
-                    .Select(a => a.Expression is IdentifierNameSyntax aid
-                        ? aid.Identifier.Text : a.Expression.ToString())
-                    .ToList();
+                var callArgs = callInv.ArgumentList.Arguments
+                    .Select(a => FlowArgName(a.Expression, model) ?? a.Expression.ToString())
+                    .ToList();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (es.Expression is InvocationExpressionSyntax callInv
&& ContractedCallee(callInv, model) is { } callee)
{
var callArgs = callInv.ArgumentList.Arguments
.Select(a => a.Expression is IdentifierNameSyntax aid
? aid.Identifier.Text : a.Expression.ToString())
.ToList();
nodes.Add(new { op = "call", callee, args = callArgs, line = LineOf(callInv) });
return true;
if (es.Expression is InvocationExpressionSyntax callInv
&& ContractedCallee(callInv, model) is { } callee)
{
var callArgs = callInv.ArgumentList.Arguments
.Select(a => FlowArgName(a.Expression, model) ?? a.Expression.ToString())
.ToList();
nodes.Add(new { op = "call", callee, args = callArgs, line = LineOf(callInv) });
return true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs` around lines 455 - 463, The
argument extraction logic in the Select statement (lines 459-461) only preserves
bare IdentifierNameSyntax and converts all other expressions to string via
ToString(), which loses the underlying identifier for wrapped expressions like
parentheses, casts, and null-forgiving operators. Create a helper method that
recursively unwraps expressions to extract the underlying identifier name: for
ParenthesizedExpressionSyntax unwrap the inner expression, for
CastExpressionSyntax extract the operand identifier, and for
PostfixUnaryExpressionSyntax with null-forgiving operator extract the operand
identifier. Update the Select statement to use this helper method instead of the
current conditional, falling back to ToString() only when no identifier can be
found after unwrapping.

Comment on lines +822 to +823
return sym.ContainingType is { } ct ? $"{ct.Name}.{sym.Name}" : sym.Name;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file
find . -name "Program.cs" -path "*/OwnSharp.Extractor/*"

Repository: PhysShell/Own.NET

Length of output: 109


🏁 Script executed:

# Check the file size first
wc -l ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 114


🏁 Script executed:

# Read the section around lines 822-823
sed -n '810,835p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1322


🏁 Script executed:

# Read the section around lines 1423-1425
sed -n '1410,1435p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1111


🏁 Script executed:

# Look for more context around line 822 to understand the function
sed -n '800,830p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1797


🏁 Script executed:

# Get better context around line 1423 - let's see what that code is doing
sed -n '1415,1430p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 670


🏁 Script executed:

# Find where MethodName function is defined
rg -n "static.*MethodName" ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 133


🏁 Script executed:

# Look at the MethodName function definition
sed -n '324,340p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 863


🏁 Script executed:

# Search for how the ContractedCallee function's return value is used
rg -n "ContractedCallee" ./frontend/roslyn/OwnSharp.Extractor/Program.cs -A 3 -B 3

Repository: PhysShell/Own.NET

Length of output: 1641


🏁 Script executed:

# Check if these keys are used to match callees across the codebase
# Look for how flowFunctions are used and how the names are matched
rg -n "flowFunctions" ./frontend/roslyn/OwnSharp.Extractor/Program.cs -B 2 -A 2

Repository: PhysShell/Own.NET

Length of output: 723


🏁 Script executed:

# Check if there's any namespace information captured elsewhere
rg -n "Namespace\|namespace" ./frontend/roslyn/OwnSharp.Extractor/Program.cs | head -20

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Look at more context around line 1423 to see the full structure
sed -n '1380,1440p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 3336


🏁 Script executed:

# Search for how the callee names are matched or looked up in the bridge/Python side
# Check if there's any matching logic that uses these names
rg -n "name.*callee\|callee.*name" ./frontend/roslyn/OwnSharp.Extractor/Program.cs -i

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Check if there's full namespace information captured anywhere
rg -n "NamespaceName\|Namespace()" ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Search for any overload handling or signature matching in the codebase
rg -n "overload\|signature\|Parameters" ./frontend/roslyn/OwnSharp.Extractor/Program.cs -i | head -30

Repository: PhysShell/Own.NET

Length of output: 43


🏁 Script executed:

# Look at the broader context of how ContractedCallee is used
# Check what happens with the callee variable returned
sed -n '450,475p' ./frontend/roslyn/OwnSharp.Extractor/Program.cs

Repository: PhysShell/Own.NET

Length of output: 1610


Include namespace qualification in ContractedCallee and function key generation to prevent collisions.

Lines 822 and 1423 generate Type.Method keys without namespace qualification. Methods with identical names and containing types across different namespaces, or method overloads, will serialize to the same key. The call op at line 456 embeds the ContractedCallee result without file context, making reliable matching to function definitions (which do include file info at line 1423) impossible when collisions occur. This breaks inter-procedural contracts for overloaded or same-named methods across namespaces.

Also applies to: 1423-1425

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs` around lines 822 - 823, The
key generation for ContractedCallee in the method containing the return
statement at line 822-823 and the function key generation at lines 1423-1425
both lack namespace qualification, causing collisions for methods with identical
names and containing types across different namespaces. Include the namespace
information from the symbol's containing namespace in both locations to fully
qualify the generated keys. This ensures that the ContractedCallee embedded in
the call op at line 456 can reliably match to function definitions and maintain
correct inter-procedural contracts for overloaded or same-named methods across
different namespaces.

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs
…olves OWN040 + review)

The call-op design crashed CI: UnitOfWorkFlowSample's consumer
ConsumeUnitOfWork(UnitOfWork uow) is a by-value IDisposable param the type-name
blast-radius grep missed, and its body is skipped by the flow pass, so the emitted
`call` op referenced an undeclared callee -> the bridge raised an unmapped OWN040 and
the extractor exited with an internal facts error. Codex (P1) and CodeRabbit flagged
the same, plus arg/param arity (OWN041) and an over-broad escape exemption.

Replace the whole call-op / params / signature machinery with the pool-Return pattern:
a call to a first-party CONSUMER (a method whose own body disposes a by-value
IDisposable parameter) is modelled as a RELEASE of the matching argument at the call
site (ConsumeReleaseArg + DisposesLocal). A use of the argument after the call is then
a use-after-handoff (OWN002), via the existing flow machinery.

This dissolves every review finding by construction:
- no `call` op -> no undeclared-callee OWN040, no arg/param OWN041 (P1/P2);
- the escape exemption is gated on the SAME ConsumeReleaseArg as the release and on the
  `Consume(s);` statement form the pass actually lowers, so an argument is exempted iff
  it is released -- never a tracked-but-unreleased false leak, and only the arg bound to
  the disposed by-value param is exempted (P3 + CodeRabbit parameter-aware);
- a callee with no body (interface/abstract/extern) or that doesn't dispose the param
  yields null -> the argument stays an ordinary escape (no crash);
- no cross-call callee-name key, so no namespace-collision concern.

Same verdicts as before: ownership-handoff-consume before -> OWN001+OWN002, after clean;
ownership-handoff-use before -> OWN002, after clean. Recall 9/11, 0 FP, floor
--min-recall 9. Validated locally: facts -> check_facts -> the exact verdicts;
test_corpus 7/7; test_ownir 88/88. Docs updated to the call-site-release framing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 711-715: Remove the early return statement in the
ConsumeReleaseArg block that comes after nodes.Add(new { op = "release", var =
carg, line = LineOf(expr) }). Instead of returning immediately after emitting
the release fact, allow the code flow to continue so that use facts can be
processed for other tracked locals in the same invocation. This ensures all
tracked arguments are properly checked and OWN002 violations are not suppressed.
- Around line 807-811: The code checks only the first element of
sym.DeclaringSyntaxReferences using index [0], which can miss valid method
bodies in partial method declarations where the first reference might lack a
body. Instead of checking only DeclaringSyntaxReferences[0].GetSyntax(), iterate
through all DeclaringSyntaxReferences to find the first one that is a
BaseMethodDeclarationSyntax and has a valid body (either a Body or
ExpressionBody property), ensuring all partial declarations are properly
evaluated before returning null.
- Around line 813-819: The current parameter-argument matching in the loop fails
when named arguments are used because it relies on positional index matching
between sym.Parameters[i] and inv.ArgumentList.Arguments[i]. Instead of using
index-based matching, check each argument in the ArgumentList for a NameColon
property; when NameColon is present, match the argument to its target parameter
by comparing the name against the parameter names in sym.Parameters, otherwise
use the positional index. This ensures that named arguments like Method(paramB:
val2, paramA: val1) are correctly mapped to their intended parameters rather
than being matched by position.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8db03da2-0799-43c1-9207-fdfaf76d194b

📥 Commits

Reviewing files that changed from the base of the PR and between 89fee9e and a25625f.

📒 Files selected for processing (4)
  • corpus/real-world/ownership-handoff-use/notes.md
  • docs/notes/corpus-benchmark.md
  • docs/proposals/P-012-bug-corpus-mining.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
✅ Files skipped from review due to trivial changes (3)
  • corpus/real-world/ownership-handoff-use/notes.md
  • docs/proposals/P-012-bug-corpus-mining.md
  • docs/notes/corpus-benchmark.md

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
…, named args)

Three correctness fixes from CodeRabbit's review of the call-site-release design
(none hit the current fixtures, but all real):

- Multi-arg calls (Major): EmitFlowExpr no longer `return`s after a consume
  release, so other tracked arguments of the same call (`Consume(s, t)`) still get
  their `use` -> a use-after-handoff on a co-argument is no longer hidden. The
  consumed arg is excluded from the use set (it is a release, never also a use).
- Partial-method bodies (Minor): scan ALL DeclaringSyntaxReferences for the first
  with a body instead of only [0], so a consumer split across partial declarations
  is still recognised.
- Named arguments (Major): map argument -> parameter by NameColon when `name:` is
  used, else by position, so `Consume(sink: s)` / reordered named args resolve to
  the right (disposed by-value IDisposable) parameter rather than by raw index.

The helper now returns the full set of consumed arguments (ConsumeReleaseArgs);
EmitFlowExpr releases each and the escape exemption uses .Contains(nm). Verdicts
unchanged: ownership-handoff-consume before -> OWN001+OWN002, use before -> OWN002,
both after clean (validated via check_facts); test_corpus 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@PhysShell
PhysShell merged commit b803d22 into main Jun 21, 2026
22 checks passed
PhysShell added a commit that referenced this pull request Jun 21, 2026
…ugh a forwarding consumer (#68)

The D4 frontier: cross-method use-after-dispose through a FORWARDING consumer. The
inter-procedural consume-handoff (#55) caught a use-after-handoff when the callee disposes a
by-value IDisposable parameter directly, but stopped one hop short — a parameter merely
forwarded to another call was left ambiguous (ownir.py's `passed` branch: "awaiting a later
transitive pass"). This is that pass.

The extractor's consumer detection (ConsumesParam) is now transitive: a parameter is consumed
if the body disposes it directly OR forwards it to another first-party consumer that consumes
it — following Consume -> Inner -> Dispose through the chain (incl. directly-called local
functions), cycle-guarded per parameter, binding each callee's body with its own tree's
SemanticModel so cross-file chains resolve. So Consume(s) is still a call-site release and a
use after it trips OWN002. The emitted facts match the direct handoff, so the core is
unchanged. Conservative: a parameter handed to an unknown or borrowing callee is not consumed
(no false release, no false OWN002).

Deferred-body safety (Codex/CodeRabbit): the scan (ImmediateInvocations, shared with
DisposesLocal) does NOT descend into nested lambda / local-function bodies — a dispose/forward
stored in a callback runs deferred, not at the call boundary, so it is not a phantom handoff.
Pinned by a silent control (DeferredHandoffNoFalsePositive) and by the new corpus case
ownership-handoff-use-transitive (before -> OWN002, after silent), which lifts the benchmark
recall floor 10 -> 11.

ruff + mypy --strict clean, corpus 9/9, ownir 116/116; corpus benchmark green at recall 11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants