# The callers a return-type assist leaves behind

> Source: <https://prod.codes/blog/the-half-that-breaks-every-caller/>
> Published: 2026-10-13 00:00:00+00:00

# The callers a return-type assist leaves behind

A September rust-analyzer probe wrapped a function’s return type without updating its callers. The first prod-code implementation added propagation candidates and a report of unresolved calls. Its limits show why the caller side needs a separate check.

## On this page · 4 sections

This post describes the Rust implementation introduced on September 23, 2026 in
[PR #110](https://github.com/alex09x/prod-code/pull/110), at
[source revision 0517151c](https://github.com/alex09x/prod-code/blob/0517151c6f37fc6b923d04cd1a638362b38d6389/crates/prod-code-mcp/src/wrap_return.rs).
The development checks and timings below are reported in the linked PRs; they were not
independently reproduced for this post. Later versions changed this tool.

The roadmap for this project listed five refactorings as “probed and not offered”: point
rust-analyzer at the obvious place and nothing comes back. It also said the probe might have
missed a trigger. [PR #108](https://github.com/alex09x/prod-code/pull/108) records a second
probe, with one example of each shape in a scratch crate: two of the five were offered.
Selected output follows; other assists and probe cases are omitted, and the labels are shortened:

```
== line 2 col 5 (an `if let` that ends the function)
convert_to_guarded_return  [RefactorRewrite]  Convert to guarded return
== line 21 col 18 (the return type of `pub fn plain() -> u32`)
wrap_return_type_in_option  [RefactorRewrite]  Wrap return type in Option
wrap_return_type_in_result  [RefactorRewrite]  Wrap return type in Result
```

The first was recorded as reachable through the assists tool. The second was half done in that probe, and it is the more instructive half to look at.

## What the assist does

``` php
-pub fn plain() -> u32 {
-    5
+pub fn plain() -> Option<u32> {
+    Some(5)
```

In the [reported example](https://github.com/alex09x/prod-code/issues/109), the signature and
returned value change, but the callers do not. A caller adding the result of `plain` to a
`u32` now has a type error: it gets an `Option<u32>`. A caller that discards the result need
not break; the assist leaving callers untouched is the problem.
The `Result` version is worse in a small way - it writes `Result<u32, _>`, a placeholder for an
error type nobody has chosen yet.

## One question per caller

For each call found in the analyzer’s reference list, the implementation examines the calling function’s written return type.

If the written type name ends in `Option` for an Option wrapper, or `Result` for a Result
wrapper, the tool proposes a `?` after the call. This is a textual test, not full type
resolution. In the simple example, `?` unwraps `Some` or `Ok`; `None` or an error propagates
from the enclosing function. That does not prove preservation of every program’s behaviour.
For a `Result`, the caller’s error type must support conversion from the new error type.
The tool sends proposed texts to an analyzer overlay and reports remaining error diagnostics;
it does not establish that every invalid conversion will be detected. Analyzer acceptance
is not compiler proof. The shared `verify: "compile"` gate was a separate option.

If it returns anything else, there is no mechanical answer. The call used to produce a `u32`;
now it may produce nothing, and what the caller should do then - use a default, panic, return
early from something that cannot return early, change its own signature too - is exactly the
decision the wrapping was meant to force. So `code_wrap_return` does not choose that recovery policy. It names the
caller, the type it returns, and the line. This abridged historical example omits diff headers,
the changed-line count and the final apply instruction:

```
`plain` (src/lib.rs)

- returned: `u32`
- now returns: `Option<u32>`
- 1 call site(s) propagate with `?`

-    Some(plain() + 1)
+    Some(plain()? + 1)

1 call site(s) cannot propagate: the calling function does not return an `Option`. Each needs a decision - unwrap, match, or wrap that caller too:
  src/lib.rs:42:37 the caller returns `u32`: `s.uses_self() + S::no_self(2) + plain()`

the analyzer rejects the result:
  the trait bound `u32: Add<Option<u32>>` is not satisfied [E0277] (src/lib.rs:42:5)

nothing was written
```

PR #110 reports that the analyzer’s complaints in this example concerned the blocked caller.
A dry run writes nothing regardless of diagnostics. On an apply request, this revision refuses
if its blocked-caller list or remaining analyzer errors are nonempty, unless `force` is set;
forcing permits the write despite those findings. For `Result`, the `_` in the signature
becomes the `error` type the caller of the tool gives, and without one the tool asks for it instead
of writing a placeholder into the code.

## Positions that moved

The implementation starts from positions returned by the analyzer. Callers in the file that declares the function are harder, because the assist has just rewritten that file: every position after the function has moved by however much the rewrite added. The rewrite only touches the function’s own span, so a position before it means the same thing it did, and a position after it moves by the difference in length.

For a reference to the selected function, a position *inside* its own span is treated as a
recursive call. PR #110 reports that the tool’s test caught it being handled wrongly on its
first run; the available source commit contains the feature and test together, not a separate
execution record establishing that chronology. The test’s function calls itself - `count(n - 1) + 1` - and the tool
reported that call as a caller that “returns `u32`”, which was true of the file on disk and false
of the file being written, where `count` now returns an `Option`. The recursive call sits inside the
span the assist rewrote, so it is now reported as what it is: a call inside the
function, for a person to finish. The PR also reports that removing the shift for positions
after the function makes the same test fail with the `?` in the wrong place. That mutation-check
result is author-reported; we have not rerun it.

## Cost and limits

PR #110 reports two warm dry runs on the scratch crate taking 0.25 and 0.23 seconds. The cost that matters is elsewhere: every caller the tool cannot rewrite is a decision someone has to make, and the report is built to make that list short to read - one line per caller, with the type that stands in the way.

The limits below belong to that September revision:

- **It does not choose for a caller.** No`unwrap` , no`unwrap_or_default` , no`match` written on
anyone’s behalf.
- **Closures are not modeled separately.** A call inside a closure is judged by the enclosing
function’s header. A proposed`?` there may be wrong; the overlay may report an error, but
this heuristic does not itself prevent an incorrect proposal.
- **The reference list is not a completeness guarantee.** This revision turns a failed reference
query into an empty list. Recursive calls and non-call references are reported as unmatched;
that list alone does not block an apply request. They need manual review even if diagnostics
are empty.
- **Rust only at that revision.** Later changes added other languages
([PR #713](https://github.com/alex09x/prod-code/pull/713) ) and custom envelopes
([#727](https://github.com/alex09x/prod-code/pull/727) ); empty-reference fallback was later
revised in[#985](https://github.com/alex09x/prod-code/pull/985) and[#1027](https://github.com/alex09x/prod-code/pull/1027) . This post is not a current capability
or safety assessment of those later implementations.

## Cite this article

```
Alexander Panasenko (2026-10-13). The callers a return-type assist leaves behind. https://prod.codes/blog/the-half-that-breaks-every-caller/
```


