notes · · 5 min

A hundred and twenty-seven problems, three of them real

Changing a type is the refactoring with the widest blast radius and the least help. Using the analyzer's diagnostics as the work list is the obvious design and it produced a report that was 97 percent noise - for a reason worth knowing about any tool built on an IDE's engine.

On this page · 6 sections
  1. The obvious design
  2. Where the other hundred and twenty-four come from
  3. The suggestion that would not appear
  4. Why it does not write the conversions
  5. What it does not do
  6. What it costs

Ask the analyzer what it can do at a field’s declared type and it offers to implement traits you have never heard of, page after page of them:

$ prod-code assists crates/prod-code-protocol/src/messages.rs 392 23
generate_delegate_trait --subtype 73  [Generate]  Generate delegate trait impl `marker::ConstParamTy_` for `timeout_secs`
generate_delegate_trait --subtype 99  [Generate]  Generate delegate trait impl `iter::traits::marker::TrustedStep` for `timeout_secs`
generate_delegate_trait --subtype 137  [Generate]  Generate delegate trait impl `fmt::UpperExp` for `timeout_secs`
…

Nothing for changing it. So changing a type is done the slow way: edit the declaration, build, read the first error, fix it, build again. You find out how big the job is by doing it.

The obvious design

The change itself is one line. Everything after it is finding out what broke, and the overlay check this series already runs before every write answers exactly that question - so code_migrate_type rewrites the declaration in memory, runs the check, and reports what comes back. The errors are not a failure. They are the work list, and having it before starting is the entire product.

The first run was this:

127 site(s) in 3 file(s) do not fit the new type

Three of them were real.

Where the other hundred and twenty-four come from

Every other line looked like this:

crates/prod-code-protocol/src/messages.rs
  6:3  type annotations needed [E0282]
      #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
  6:3  type annotations needed [E0282]
      #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
  6:3  type annotations needed [E0282]
      #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

The same position, the same message, over and over, on a line that contains no code at all.

The first fix went after the symptom. Collapse the exact repeats and 127 becomes 40, of which 37 are attribute positions and three are code; set aside every diagnostic whose line of source begins with #[, count them in a footnote, and the list is three sites long. It read well, and the story that came with it sounded right: the derive generates code that mentions the field whose type just changed, the analyzer cannot make sense of that code, and every attempt fails at the attribute.

The story was wrong, and one command shows it. Ask the check about the file with nothing changed at all:

$ prod-code validate crates/prod-code-protocol/src/messages.rs --from crates/prod-code-protocol/src/messages.rs
crates/prod-code-protocol/src/messages.rs: 124 error(s), 0 warning(s)

All 124 were there before the migration touched anything. The analyzer expands serde’s derives - give a struct a field of a type that does not exist and it says the trait bound `!: Deserialize<'_>` is not satisfied at that field, which only the generated code can say - and then fails to infer types inside what Deserialize generates. It does that for every such derive in the workspace, edit or no edit: 124 in this file, 50 in another file in the workspace, 26 in a third. The post on validating an edit before writing it warned that the analyzer’s view is not the compiler’s view. That warning was about generated code the analyzer does not have. This is generated code it has, and misreads.

And it is not the migration’s problem. It is every write tool’s: the check behind all of them counted those 124, so any refactoring that touched the file ended with “the analyzer rejects the result” and refused to write. The fix went under all of them (#79). Each file’s diagnostics are taken as it is on disk before the proposed text is opened, and one the file already had - the same severity, code and message, on a line with the same text - is set aside and counted rather than blamed on the edit. A second copy of an old error on a new line is still the edit’s.

The declaration changes in memory, the overlay check runs, and 127 diagnostics come back. The file was asked about first, as it is on disk, and 124 of them were already there before the edit; those are set aside and counted, not blamed on it. What is left is three sites, each with its file, line and source line, two of them carrying a suggested conversion
The same check, asked twice: once about the file as it is, once about the edit. The difference is the work list.

With it, the same migration needs no footnote at all:

`timeout_secs` (crates/prod-code-protocol/src/messages.rs)

- was: `u64`
- now: `std::time::Duration`

3 site(s) in 2 file(s) do not fit the new type:

crates/prod-code-gateway/src/lib.rs
  1406:53  the trait bound `Duration: PartialEq<i32>` is not satisfied [E0277]
      let timeout = std::time::Duration::from_secs(if req.timeout_secs == 0 {
  1409:13  expected u64, found Duration [E0308]
      req.timeout_secs
      try: this place still wants `u64` and is now given `Duration`; migrate it too, or convert back here

crates/prod-code-mcp/src/exec.rs
  68:13  expected Duration, found u64 [E0308]
      timeout_secs,
      try: the value here is still `u64`; convert it to `Duration`

Three sites, each with the line of source at it, and two of them with the conversion named. That is a thing a person can plan an afternoon around. The two filters stay, for the case they do describe: an error the change itself causes inside a derive’s output lands on the attribute too, and there is still nothing at that position to edit.

The suggestion that would not appear

The try: lines are the other half, and they did not work at first. The rule is simple enough - when the error says expected A, found B and A and B are the new and old types, say which direction the conversion goes - and it matched nothing, because the declaration spells std::time::Duration and the analyzer says Duration.

Of course it does. The analyzer names a type the way it is in scope at the site, not the way the declaration writes it. Comparing the last path segment fixes it, and the general form is worth keeping: a type has more than one true spelling, and which one you get depends on who is talking.

Why it does not write the conversions

It could. For the expected Duration, found u64 case, Duration::from_secs(x) is almost always what you meant, and inserting it at every such site would look like a much better tool.

It would also be wrong wherever the value is not seconds. Nothing in the type says which, and the sites where it matters are exactly the ones nobody re-reads afterwards, because the tool said it handled them. A page of confident conversions, some of them silently wrong, is a worse outcome than a list.

So the tool says the conversion and does not write it, and apply writes the declaration alone and refuses while any site remains - because a half-migrated type in the tree is worse than an unmigrated one. The description says, in its own words, that this is the first half of a migration. The roadmap’s version promises a whole-program constraint graph and automatic conversion injection; that is not this, and pretending otherwise would be the most expensive sentence in the documentation.

What it does not do

  • Four shapes of declaration: a struct field, a function parameter, a return type, an annotated let. A type used somewhere else - a generic bound, an associated type - is not found and the tool says so rather than editing the wrong span.
  • One declaration at a time. Migrating a type through a graph of them is the job it reports on, not the job it does.
  • It trusts the check for what the check is good at. Type mismatches are exactly that - unlike unresolved types and module paths, which it is not.
  • Rust only.

What it costs

One references call to decide which other files to check, and one overlay check across the workspace - about a second against a warm gateway for the run above.

Against building to find out, the saving is not the second. It is that the list arrives whole, before the first edit, instead of one error at a time over the following hour.

Cite this article
Citation
Alexander Panasenko (2026-10-06). A hundred and twenty-seven problems, three of them real. https://prod.codes/blog/a-hundred-and-twenty-seven-problems/