notes · · 6 min

Hover lies by omission

A struct with eleven fields shows ten, so any test value built from that answer is missing one and nothing says so until a build. What it takes to generate a fixture you can trust: read the declaration rather than the summary, and type-check the result before handing it over.

On this page · 4 sections
  1. Read the declaration, not the summary
  2. Filling a field is mechanical; knowing you got it right is not
  3. What it does not do
  4. What it costs

A test needs a value of a type. The type has eleven fields, the agent writing the test knows none of them, so it asks the analyzer for the shape. This is the right instinct — the analyzer is the one thing in the room that cannot be out of date — and here is what comes back:

$ prod-code hover crates/prod-code-protocol/src/messages.rs 613 12   # the declaration, by position

pub struct ShadowRunRequest {
    pub client_workspace_root: String,
    pub base_workspace_name: Option<String>,
    pub hypotheses: Vec<ShadowHypothesis>,
    pub command: Vec<String>,
    pub env: Vec<(String, String)>,
    pub timeout_secs: u64,
    pub subdir: Option<String>,
    pub parallel: usize,
    pub tail_bytes: usize,
    pub client_agent: Option<String>,
    /* … */
}

Ten fields and an ellipsis. The eleventh is client_host. Hover is a summary meant for a human glancing at a tooltip, so rust-analyzer stops this one after ten fields and says so with /* … */ — a limit that is a setting, not a law, which is exactly why nothing downstream should depend on where it falls.

A struct literal written from that answer is missing a field. It does not fail at the point of the mistake; it fails later, in a build, with an error the agent then has to route back to the thing it wrote three steps ago. Nothing about the hover said “there is more”.

Read the declaration, not the summary

code_generate_fixture asks a different question. Instead of “describe this type”, it resolves the name to the place the type is declared and reads the declaration’s own text, then fills each field by its type:

$ prod-code fixture ShadowRunRequest
fixture for `ShadowRunRequest` (declared in crates/prod-code-protocol/src/messages.rs)

let shadow_run_request = ShadowRunRequest {
    client_workspace_root: String::new(),
    base_workspace_name: None,
    hypotheses: Vec::new(),
    command: Vec::new(),
    env: Vec::new(),
    timeout_secs: 0,
    subdir: None,
    parallel: 0,
    tail_bytes: 0,
    client_agent: None,
    client_host: None,
};

the analyzer accepts it: 0 errors

Eleven fields, client_host among them.

Two paths from one type name. The left path is hover: a summary that stops after ten fields and drops the eleventh, producing a literal that is missing one. The right path reads the declaration's own text, fills every field by type, and places the result in an in-memory overlay where the analyzer type-checks it before it is returned
The same analyzer answers both. One is a summary for a person, the other is the declaration itself — and only one of them is safe to build a value from.

That first hover took a file and a position for a reason. ShadowRunRequest is three symbols in this repository, and the case for addressing a tool by symbol rather than by position explains why a name that resolves twice is reported rather than guessed at:

{ "name": "code_hover", "arguments": { "symbol": "ShadowRunRequest" } }

`ShadowRunRequest` is ambiguous (3 candidates); qualify it (Type::name) or pass `path`:
  [Struct]     ShadowRunRequest              crates/prod-code-protocol/src/lib.rs:13:47
  [EnumMember] WireMessage::ShadowRunRequest crates/prod-code-protocol/src/messages.rs:33:5
  [Struct]     ShadowRunRequest              crates/prod-code-protocol/src/messages.rs:613:12

(That one is the MCP form; the CLI’s hover takes a position, which is why the transcript above does.) A re-export in the crate root, a same-named variant of the message enum, and the declaration. Here the ambiguity can be resolved rather than reported, because only one kind of symbol can be built: the generator keeps structs and enums, drops re-export sites when a real declaration exists, and falls back to asking for a path when that still leaves two.

Filling a field is mechanical; knowing you got it right is not

Integers and floats become 0 and 0.0, bool becomes false, String becomes String::new() and PathBuf PathBuf::new(), Option becomes None, collections come back empty, a Duration is from_secs(0), a wrapper (Box, Arc, Rc, Mutex, RwLock, RefCell, Cell) is built around its inner value, an enum takes its first unit variant. Types declared in the workspace are built field by field, down to depth:

$ prod-code fixture DivergentBenchReport
let divergent_bench_report = DivergentBenchReport {
    language: Language::Rust,
    mode: WorkspaceMode::Shared,
    persistent: false,
    churn_percent: 0,
    sessions_dropped: 0,
    gateway_after: None,
    workspace_name: String::new(),
    target: DivergentTarget {
        language: Language::Rust,
        file_rel: std::path::PathBuf::new(),
        symbol: String::new(),
        line: 0,
    },
    initial_syncs: Vec::new(),
    total_queries: 0,
    total_errors: 0,
    elapsed: std::time::Duration::from_secs(0),
    qps: 0.0,
    latency: LatencyStats {
        count: 0,
        min_ms: 0.0,
        p50_ms: 0.0,
        p95_ms: 0.0,
        p99_ms: 0.0,
        max_ms: 0.0,
    },
    latency_by_kind: BTreeMap::new(),
    errors_by_kind: BTreeMap::new(),
    verifications: Vec::new(),
    all_passed: false,
};

the analyzer accepts it: 0 errors

Eighteen fields, two nested structs built field by field, two maps, a Duration, an f64, two enums taken at their first variant. Every one of those rules is a guess about what a type’s empty value looks like, and guesses are wrong eventually. So the last step is not a rule at all: the fixture is placed in a #[cfg(test)] probe module inside an in-memory overlay of the file that declares the type — the same mechanism that judges a patch before it is written uses — and the analyzer is asked whether it compiles. Nothing is written to disk in any mode, and depth defaults to 2.

Watch it earn that, by asking for a fixture that cannot work — --depth 0 means “do not build nested types, fall back to Default::default()”:

$ prod-code fixture DivergentBenchReport --depth 0

`Default::default()` stands in for: DivergentTarget, Language, LatencyStats, WorkspaceMode
  (not declared in this workspace, or deeper than the depth limit)

the analyzer rejects it:
  the trait bound `Language: Default` is not satisfied [E0277]
    (crates/prod-code-client/src/divergent_bench.rs:1757:15)
  the trait bound `DivergentTarget: Default` is not satisfied [E0277]
    (crates/prod-code-client/src/divergent_bench.rs:1764:13)

Default::default() is a perfectly reasonable fallback that happens to be wrong for these two types. Without the check, that fixture looks fine, goes into a test, and fails on the next build with an error about a trait bound, three steps from where it was written. With the check it comes back as the analyzer’s own message, immediately, and with the list of types that fell back so you can see which ones to fill in by hand.

What it does not do

  • Rust only. The shape comes from rust-analyzer, linked into the gateway. We have not built this for the other engines, so the tool is absent for them rather than degraded.
  • It writes a literal, not a constructor. If a type has a new or a builder that enforces an invariant, the fixture goes around it. The fallback list and the type check make the literal’s limits visible, which is what an agent needs in order to decide; picking the right constructor needs argument values that are themselves fixtures, and the literal had to be right first.
  • The check happens where the type is declared. That file’s imports are in scope during it, so a fixture that uses BTreeMap::new() may still need an import when pasted into another module. The tool says so in its own description rather than pretending otherwise.
  • Default::default() past the depth limit is a guess, named in the output as one.

What it costs

Measured the same way for each row — time prod-code … from a shell, against a warm gateway on the LAN, with a development build of the client:

hover — a bare round trip, for reference 0.09 s
fixture ShadowRunRequest --no-verify (11 fields, flat) 0.25 s
fixture ShadowRunRequest (type-checked) 1.04 s
fixture DivergentBenchReport --no-verify (18 fields, nested) 0.52 s
fixture DivergentBenchReport (type-checked) 1.00 s

Every row of that table was four to nine tenths of a second slower while this post was being written, and none of it was the analyzer. PROD_CODE_TIMING=1 on the first row prints total=70.7ms connect=1.5ms preflight_sync=55.0ms handshake=6.7ms initialize=1.3ms did_open_sent=4.9ms query_response=1.3ms — the query itself is 1.3 ms, and the ~80 ms that moving the analyzer off the laptop and the round-trip breakdown measured for a one-shot query is that total, most of it two git subprocesses. The rest of the wall clock was outside what that timer bracketed: the two questions an invocation asks a gateway before its query, each answered by probing for installed language servers, one probe of which starts npm. That was issue 56, and it is fixed — the probe is taken at startup now, not per request.

What is left is the tool’s own cost, and the table says where it goes: resolving the symbol and reading the declaration is 0.16 s on the flat type and 0.43 s on the nested one, which is one document-symbol call per type it descends into; the type check adds 0.79 s and 0.48 s. Do not read an order into those two. The measurements taken before the gateway fix put them the other way round — 0.47 s on the flat type, 0.95 s on the nested one — so the honest statement is that the check costs between four and nine tenths of a second, and which type pays more is not stable between runs. That is unsurprising once you know what is being judged: not the fixture, but the file the type is declared in, with the fixture in a probe module inside it. The check buys the only thing that separates this from a template: an answer that is either correct or an error, never a plausible literal that a build will reject later.

Cite this article
Citation
Alexander Panasenko (2026-09-29). Hover lies by omission. https://prod.codes/blog/hover-lies-by-omission/