notes · · 4 min

The refactoring that changes nothing

Promoting a magic value to a parameter is only safe if every existing caller keeps passing the value it had. That constraint is the whole design - and it is also why the tool refuses some extractions outright, and why the analyzer's answer to 'what function is this in' was the wrong question.

On this page · 6 sections
  1. The success condition is that nothing happens
  2. What it refuses, and why that follows from the same rule
  3. The smallest declaration is not the enclosing function
  4. Asking for the type instead of guessing it
  5. What it does not do
  6. What it costs

Select an expression in a Rust file and ask the analyzer what it can do with it:

$ prod-code assists crates/prod-code-mcp/src/move_item.rs 629 19 --to 629:39
extract_variable  [RefactorExtract]  Extract into variable
extract_constant  [RefactorExtract]  Extract into constant
extract_static  [RefactorExtract]  Extract into static
extract_function  [RefactorExtract]  Extract into function

Four ways to take an expression out of where it is. All four stay inside the file, and that is why they are there: the fifth one, making it a parameter, has to touch every place that calls the function.

The success condition is that nothing happens

Adding a parameter is easy. Adding one without changing what any existing caller does is the entire job, and there is exactly one way to do it: every call site passes the expression the body used to have. Afterwards the program computes what it computed before, byte for byte, in every caller - provided the expression does not depend on when it is evaluated, because it is now evaluated at the call rather than inside the body.

The value of the refactoring is not in what it changes. It is in what it makes possible for the next caller, which now has a choice where before there was a constant buried in a body.

$ prod-code extract-parameter crates/prod-code-mcp/src/signature.rs 96 37 --to 96:38 \
    --name context_lines --type usize
`render` (crates/prod-code-mcp/src/signature.rs)

- new parameter: `context_lines: usize`
- from the body: `2`
- 1 place(s) in the body now read it, 1 call site(s) pass it

6 changed line(s) in 2 file(s)

--- a/crates/prod-code-mcp/src/signature.rs
-    pub fn render(&self, diff_budget: usize) -> String {
+    pub fn render(&self, diff_budget: usize, context_lines: usize) -> String {
-                    .context_radius(2)
+                    .context_radius(context_lines)
--- a/crates/prod-code-mcp/src/tools.rs
-            let text = change.render(6000);
+            let text = change.render(6000, 2);

the analyzer accepts the result: 0 errors

Three edits, and the third is the one that matters: render(6000) became render(6000, 2), not render(6000, some_new_default). The one existing caller still asks for a radius of two, because two is what the body had.

What it refuses, and why that follows from the same rule

If the expression is written at every call site, it has to be spellable at every call site. An expression that names a local of the function it came from is not:

$ prod-code extract-parameter crates/prod-code-mcp/src/move_item.rs 629 19 --to 629:39 \
    --name removed_lines --type u32
`move_item` (crates/prod-code-mcp/src/move_item.rs)

- new parameter: `removed_lines: u32`
- from the body: `decl_end - start + 1`
- 1 place(s) in the body now read it, 4 call site(s) pass it
…
the analyzer rejects the result:
  no such value in this scope [E0425] (crates/prod-code-mcp/src/tools.rs:1726:36)
  no such value in this scope [E0425] (crates/prod-code-mcp/tests/orchestration.rs:577:99)
  …

the expression is now written at every call site: if it names a local, a parameter or anything
private to the function it came from, it cannot be spelled there. Extract something the callers
can see.

nothing was written; pass `apply: true` to make these edits

decl_end and start are locals of move_item, and the call sites are four, so eight diagnostics say the same thing in the analyzer’s words; the line underneath says it in the reader’s. This is the same refusal as the private sibling a moved item leaves behind - a thing that was in scope where it was written and is not in scope where it is going.

Worth noting, after the last post: here the check does catch it. An unresolved value is reported where an unresolved type is not, so this particular refusal is one the analyzer can be trusted for.

The expression leaves the body in three coordinated edits: the parameter is appended to the declaration, the body reads the parameter, and every existing call site is given the expression that used to be in the body. Underneath, the consequence: every current caller computes exactly what it computed before, and the choice exists only for the next one. Below that, the condition that makes it possible: the expression has to be spellable where the callers are, so one that names a local of this function is refused
Three edits whose combined effect on every existing caller is nothing at all, as long as the expression does not care when it runs. That is the property being preserved, and the refusal falls straight out of it.

The smallest declaration is not the enclosing function

The first run against this repository failed like this:

Error: `removed` is not a function

The tool asks the analyzer for the file’s symbols and takes the smallest one containing the selection. For an expression inside let removed = decl_end - start + 1;, the smallest symbol containing it is removed - textDocument/documentSymbol reports local bindings, and a local binding is a perfectly good symbol. It is just not something that can take a parameter.

The fix is one clause: consider only symbols whose kind is a function or a method. The general lesson is smaller than it looks and worth writing down anyway - “the innermost thing” and “the thing that can do what I am about to do” are different queries, and the analyzer answers the first one because that is what it was asked.

Asking for the type instead of guessing it

The parameter needs a type. Hover knows it, and hover says it in whatever shape suits the thing under the cursor:

let decl_end: u32          # a local binding
core::str                  # a type

The first parses. The second is not a type annotation at all, and an arbitrary expression can produce either, or nothing. So the rule is: take the type when hover gives the shape this can read, and ask the caller when it does not. A type guessed out of a hover string is precisely the kind of plausible wrong answer this whole series exists to avoid, and --type usize is four words.

What it does not do

  • It moves when the expression runs. Evaluation happens at the call now, not in the body. For a literal or a constant that is nothing; for Instant::now() or anything reading state the body changes first, it is a real difference the tool cannot see.
  • It puts the parameter last. Position and ordering are code_change_signature’s job.
  • No default. Rust has none, and inventing one would change what a caller does, which is the one thing this refactoring is defined not to do.
  • One function. The selection has to be inside a body; a selection in the signature is refused rather than reinterpreted.
  • replace_all is per function, not per file: every identical occurrence inside the body this selection belongs to.
  • Rust only, and re-run your formatter.

What it costs

One documentSymbol, one references, one hover when the type was not given, and one overlay check across the changed files. About a second against a warm gateway.

The number that matters is a different one: zero. That is how many existing callers behave differently afterwards, and the tool is built entirely around keeping it there.

Cite this article
Citation
Alexander Panasenko (2026-10-05). The refactoring that changes nothing. https://prod.codes/blog/the-refactoring-that-changes-nothing/