A truncated extraction is disclosed only by index_coverage — every other tool serves the partial file silently #101

Closed
opened 2026-09-04 12:32:34 +02:00 by buildagent · 1 comment
Member

Reported from the field against de.h-dv.xaml 0.1.0 on a real 2 406-file XAML solution, and verified in the source.

What the customer measured

Large XAML truncates silently. wndBeleg.xaml, wndItem.xaml → extract.truncated_tree; the extractor stops early and everything past the cut is missing from every tool. Small files are clean. index_coverage(path) distinguishes them.

They found it, correctly diagnosed it, and had to keep the knowledge by hand.

Verified

extraction_diagnostics is a field of the index_coverage reply and nothing else. In crates/mcp-server/src/server.rs it is declared inside that tool's scope and populated from overlap.extraction_diagnostics; every other construction of that struct passes None. No other tool reads it.

So for a file that reports extract.truncated_tree:

tool what it does
index_coverage discloses it — "SYMBOLS ARE MISSING from an otherwise indexed file and you must use shell for the rest of it"
search_symbols, file_outline, find_callers, find_references, read_code, change_impact, context_pack, safe_delete, check_rename serve the partial file with no indication

Why this outranks the neighbouring disclosure gaps

This is not an unmeasured zero (#99). Rows that should exist do not exist, and the file is otherwise a perfectly ordinary indexed row. Consequences that follow directly:

  • ref_count on any symbol in a truncated file is wrong, not merely a floor — references past the cut were never extracted.
  • find_callers returns fewer callers than exist, and its confidence block says only the standard "unresolved, external, and dynamic uses may be absent" — which is true of every file and does not describe this one.
  • safe_delete and check_rename are the dangerous pair. Their whole contract is "never say safe on incomplete evidence". A truncated file is incomplete evidence by construction, and neither tool can currently see that it is looking at one. safe_delete saying a symbol has no remaining uses, computed over a file whose second half was never read, is the failure mode those tools exist to prevent.

The project already has the vocabulary for this. index_coverage's own description states the rule — null is NOT REPORTED, [] is a measurement, non-empty means symbols are missing — and it is applied on one surface out of a dozen.

Same shape as #99, one level worse

#99 is find_callers returning a structural zero with no disclosure while search_symbols explains the same fact. This is that pattern with missing data instead of an unmeasurable count. Both are "a fact the system knows, disclosed on one surface and silent on its neighbours", and both should probably be fixed by the same mechanism rather than one at a time — the #97 fix covered all 23 tools by construction for exactly this reason.

What the fix needs to be

Any tool whose answer is derived from a file with a non-empty extraction_diagnostics should say so, in the reply, per result — not in a tool description, which nobody reads at the moment they need it (see the standing rule that disclosures belong in the payload).

Sketch, reusing the existing vocabulary rather than a second one:

{"results":[...],
 "partial_sources":[{"path":"UI/wndBeleg.xaml","diagnostics":["extract.truncated_tree"]}],
 "partial_semantics":"one or more files behind this answer reported an INCOMPLETE extraction:
   rows past the cut were never produced, so counts here are lower bounds and an absent row
   is not evidence of absence. `index_coverage(path)` names what each producer reported."}

Absent when no source was truncated, present when one was — so a reply without the field keeps meaning "nothing behind this was partial".

safe_delete and check_rename should go further than disclosing. Given their never-say-safe contract, a truncated file among the evidence should downgrade the verdict rather than annotate it.

Also worth checking

  • project_overview's file_health block lists per-file ref counts and does not appear to carry truncation either — a file that is silently half-indexed looks like a file with genuinely few refs.
  • Whether any builtin extractor can produce a truncated tree, or whether this is package-only today. The customer's case is a package (XAML), but the field is generic and the answer changes how urgent this is for non-package users.

Reported alongside

Two other findings from the same session, both already handled: no_use_reference_channel on 30 479 XAML controls — the disclosure working correctly, the customer read it right — and plugin_add's poll instruction naming a condition that could never become true, now corrected and guarded.

Reported from the field against `de.h-dv.xaml 0.1.0` on a real 2 406-file XAML solution, and verified in the source. ## What the customer measured > Large XAML truncates silently. `wndBeleg.xaml`, `wndItem.xaml` → `extract.truncated_tree`; the extractor stops early and **everything past the cut is missing from every tool**. Small files are clean. `index_coverage(path)` distinguishes them. They found it, correctly diagnosed it, and had to keep the knowledge by hand. ## Verified `extraction_diagnostics` is a field of the **`index_coverage` reply and nothing else**. In `crates/mcp-server/src/server.rs` it is declared inside that tool's scope and populated from `overlap.extraction_diagnostics`; every other construction of that struct passes `None`. No other tool reads it. So for a file that reports `extract.truncated_tree`: | tool | what it does | |---|---| | `index_coverage` | discloses it — "SYMBOLS ARE MISSING from an otherwise `indexed` file and you must use shell for the rest of it" | | `search_symbols`, `file_outline`, `find_callers`, `find_references`, `read_code`, `change_impact`, `context_pack`, `safe_delete`, `check_rename` | serve the partial file with **no indication** | ## Why this outranks the neighbouring disclosure gaps This is not an unmeasured zero (#99). **Rows that should exist do not exist**, and the file is otherwise a perfectly ordinary `indexed` row. Consequences that follow directly: - `ref_count` on any symbol in a truncated file is **wrong**, not merely a floor — references past the cut were never extracted. - `find_callers` returns fewer callers than exist, and its `confidence` block says only the standard "unresolved, external, and dynamic uses may be absent" — which is true of every file and does not describe *this* one. - **`safe_delete` and `check_rename` are the dangerous pair.** Their whole contract is "never say safe on incomplete evidence". A truncated file is incomplete evidence by construction, and neither tool can currently see that it is looking at one. `safe_delete` saying a symbol has no remaining uses, computed over a file whose second half was never read, is the failure mode those tools exist to prevent. The project already has the vocabulary for this. `index_coverage`'s own description states the rule — `null` is NOT REPORTED, `[]` is a measurement, non-empty means symbols are missing — and it is applied on one surface out of a dozen. ## Same shape as #99, one level worse #99 is `find_callers` returning a structural zero with no disclosure while `search_symbols` explains the same fact. This is that pattern with **missing data** instead of an unmeasurable count. Both are "a fact the system knows, disclosed on one surface and silent on its neighbours", and both should probably be fixed by the same mechanism rather than one at a time — the #97 fix covered all 23 tools by construction for exactly this reason. ## What the fix needs to be Any tool whose answer is derived from a file with a non-empty `extraction_diagnostics` should say so, in the reply, per result — not in a tool description, which nobody reads at the moment they need it (see the standing rule that disclosures belong in the payload). Sketch, reusing the existing vocabulary rather than a second one: ```json {"results":[...], "partial_sources":[{"path":"UI/wndBeleg.xaml","diagnostics":["extract.truncated_tree"]}], "partial_semantics":"one or more files behind this answer reported an INCOMPLETE extraction: rows past the cut were never produced, so counts here are lower bounds and an absent row is not evidence of absence. `index_coverage(path)` names what each producer reported."} ``` Absent when no source was truncated, present when one was — so a reply without the field keeps meaning "nothing behind this was partial". **`safe_delete` and `check_rename` should go further than disclosing.** Given their never-say-safe contract, a truncated file among the evidence should downgrade the verdict rather than annotate it. ## Also worth checking - `project_overview`'s `file_health` block lists per-file ref counts and does not appear to carry truncation either — a file that is silently half-indexed looks like a file with genuinely few refs. - Whether any *builtin* extractor can produce a truncated tree, or whether this is package-only today. The customer's case is a package (XAML), but the field is generic and the answer changes how urgent this is for non-package users. ## Reported alongside Two other findings from the same session, both already handled: `no_use_reference_channel` on 30 479 XAML controls — the disclosure working correctly, the customer read it right — and `plugin_add`'s poll instruction naming a condition that could never become true, now corrected and guarded.
Author
Member

Fixed, together with #99, by one mechanism rather than a fix per tool.

annotate_evidence_gaps — a single post-dispatch grader

It sits in CodeIndexServer::call_tool above the router, exactly where the #97 argcheck ladder sits. That placement is the whole point: tool 24 is covered without opting in, and nobody has to remember to annotate a new tool. It parses the finished reply, grades it, and rewrites it.

Two graders feed one evidence_gaps block:

  • #101 — partial sources. New IndexAccess::partial_sources(), deliberately with no path argument: the file that corrupts an answer is the one the answer cannot name, so asking "is this path partial?" would miss exactly the case that matters. Backed by read_partial_sources and a new partial index (m0058_partial_source_index.rs, schema 57→58 — measured 20 ms → below timer resolution on a synthetic 200 000-contribution index; the probe runs once per tool call).
  • #99 — unmeasured population. Reads SymbolRow::name_fallback_unmeasured off get_symbol, which is the same field from the same producer search_symbols already publishes, so the two tools cannot drift into disagreeing about one symbol.

Matching is by exact whole-string equality against every string in the payload, so path fields, bare Vec<String> lists like text_occurrence_files, and any shape added later are covered without the grader knowing a single field name.

Coverage is measured, not asserted: the sweep drives 18 tools against a truncated index and against a clean one.

The three states are kept apart

state rendering
measured, nothing partial block absent
could not be asked (old daemon, no active generation) partial_sources_unmeasured: ["primary"] — renders "could not report", never "none"
the finding partial_sources + partial_sources_in_index

partial_sources_in_index is COUNT(DISTINCT path), never sources.len(), and is absent when no project answered. The unmeasured state was verified live against this repo's running v0.26.1 daemon.

safe_delete / check_rename no longer merely annotate

When partial_sources_in_index > 0 or the probe is unmeasured, no_evidence_of_use / clean are removed from reasons and evidence_incomplete leads; what was taken out is named in evidence_gaps.downgraded. Written client-side, so an older daemon's reply is downgraded too and no reason-code value crosses the wire.

This was narrowed once, on evidence. The first version also downgraded on #99's fact, and the pre-existing refactor_tools_disclose_a_saturated_fts_candidate_window went red on Widget — an ordinary struct — because no_use_reference_channel fires wherever the index demonstrates no channel for a (kind, lang) pair, which on a small repo is most kinds. A guard that fires on everything discloses nothing. So #99 gets disclosure beside the verdict; #101 gets the downgrade.

Mutations — all RUN, all RED

mutation red
delete the grader call from call_tool 4 e2e + the call-site gate: "has 0 call sites … zero discloses nothing"
is_silent() → always false "search_symbols invented an evidence gap on a clean index"
downgrade_absence_verdict → None "the absence verdict must be REMOVED, not annotated" beside a populated block
probe_unmeasured_population → empty left: None / right: Some(2) on a body that is verbatim #99's bug report
drop m0058's WHERE "it must be PARTIAL over the diagnostics predicate"
list all partials, not matched ones "a reply naming no partial file must not list one"
rename the daemon dispatch arm "None is the three-state 'could not be asked' … every MCP reply silently degraded"
total = sources.len() left: Some(500) / right: Some(501)

Both directions everywhere: a clean index emits no block, and a function nothing calls still reports a bare total: 0.

Two gates I wrote were themselves wrong first, and both notes are in the test docs: the call-site gate was brittle to rustfmt line-breaking, and the 501-file cap fixture originally INSERTed rows — the server reconciles away paths with no file on disk, so it measured 501 in SQL while the server reported 1.

The open question answered: project_overview.file_health carried nothing

FileResolution is path/refs/resolved/no_candidate/internal_missed/unresolved_qualified/unresolved_bare — no truncation field. Demonstrated: a truncated file reports {"path":"src/other.rs","refs":3,"resolved":3,"internal_missed":0}, a perfectly healthy-looking row. The new mechanism names that exact path in partial_sources in the same reply.

Scope, stated rather than implied

  • Package-only today. parse_with_plugin returns Parse::Ok(e, Vec::new()) under the comment "NO DIAGNOSTIC CHANNEL EXISTS ON THIS ARM"; the only non-empty producer is packages::facts_diagnostics, reachable solely from TaskSource::Package. A builtin extractor cannot produce a truncated tree — but "all parsers become plugins" (#75/#84) makes every user a package user, and this repo's own index already carries one.
  • Resources bypass the grader — read_resource serves the same data ungraded. Deliberate: both issues are about tool reports. Worth a follow-up.
  • read_code gets the block too. Its bytes are unaffected by truncation; the uniform rule is the price of "no per-tool allowlist", and the wording was softened accordingly.
  • False-positive shape: a snippet whose entire text equals a partial file's path would match. No instance exists in this repo, but it is a shape, not an impossibility.

Cost: ~0.06–0.11 ms per call (0.252/0.256 → 0.314/0.364 ms, isolated release, 300 calls).

Gates: fmt, clippy native and x86_64-pc-windows-gnu, rustdoc, cargo test --workspace 2849 passed / 0 failed, daemon-leg mcp-server 555 passed / 0 failed (proving the block crosses the real RPC wire), corpus ratchet unmoved at 534084b856c22566c48e386bc41ed67e, precision_gate 7/7.

## Fixed, together with #99, by **one** mechanism rather than a fix per tool. ### `annotate_evidence_gaps` — a single post-dispatch grader It sits in `CodeIndexServer::call_tool` **above the router**, exactly where the #97 argcheck ladder sits. That placement is the whole point: tool 24 is covered without opting in, and nobody has to remember to annotate a new tool. It parses the finished reply, grades it, and rewrites it. Two graders feed one `evidence_gaps` block: - **#101 — partial sources.** New `IndexAccess::partial_sources()`, deliberately **with no path argument**: the file that corrupts an answer is the one the answer cannot name, so asking "is *this* path partial?" would miss exactly the case that matters. Backed by `read_partial_sources` and a new partial index (`m0058_partial_source_index.rs`, schema 57→58 — measured 20 ms → below timer resolution on a synthetic 200 000-contribution index; the probe runs once per tool call). - **#99 — unmeasured population.** Reads `SymbolRow::name_fallback_unmeasured` off `get_symbol`, which is **the same field from the same producer `search_symbols` already publishes**, so the two tools cannot drift into disagreeing about one symbol. Matching is by **exact whole-string equality against every string in the payload**, so `path` fields, bare `Vec<String>` lists like `text_occurrence_files`, and any shape added later are covered without the grader knowing a single field name. Coverage is **measured, not asserted**: the sweep drives **18 tools** against a truncated index and against a clean one. ### The three states are kept apart | state | rendering | |---|---| | measured, nothing partial | block **absent** | | could not be asked (old daemon, no active generation) | `partial_sources_unmeasured: ["primary"]` — renders "could not report", never "none" | | the finding | `partial_sources` + `partial_sources_in_index` | `partial_sources_in_index` is `COUNT(DISTINCT path)`, never `sources.len()`, and is **absent** when no project answered. The unmeasured state was verified live against this repo's running v0.26.1 daemon. ### `safe_delete` / `check_rename` no longer merely annotate When `partial_sources_in_index > 0` **or** the probe is unmeasured, `no_evidence_of_use` / `clean` are **removed** from `reasons` and `evidence_incomplete` leads; what was taken out is named in `evidence_gaps.downgraded`. Written client-side, so an older daemon's reply is downgraded too and no reason-code value crosses the wire. **This was narrowed once, on evidence.** The first version also downgraded on #99's fact, and the pre-existing `refactor_tools_disclose_a_saturated_fts_candidate_window` went red on `Widget` — an ordinary struct — because `no_use_reference_channel` fires wherever the index *demonstrates* no channel for a (kind, lang) pair, which on a small repo is most kinds. A guard that fires on everything discloses nothing. So #99 gets disclosure beside the verdict; #101 gets the downgrade. ### Mutations — all RUN, all RED | mutation | red | |---|---| | delete the grader call from `call_tool` | 4 e2e + the call-site gate: "has 0 call sites … zero discloses nothing" | | `is_silent()` → always false | "`search_symbols` invented an evidence gap on a clean index" | | `downgrade_absence_verdict` → `None` | "the absence verdict must be REMOVED, not annotated" beside a populated block | | `probe_unmeasured_population` → empty | `left: None / right: Some(2)` on a body that is **verbatim #99's bug report** | | drop m0058's `WHERE` | "it must be PARTIAL over the diagnostics predicate" | | list all partials, not matched ones | "a reply naming no partial file must not list one" | | rename the daemon dispatch arm | "None is the three-state 'could not be asked' … every MCP reply silently degraded" | | `total = sources.len()` | `left: Some(500) / right: Some(501)` | Both directions everywhere: a clean index emits **no block**, and a function nothing calls still reports a bare `total: 0`. **Two gates I wrote were themselves wrong first**, and both notes are in the test docs: the call-site gate was brittle to `rustfmt` line-breaking, and the 501-file cap fixture originally INSERTed rows — the server reconciles away paths with no file on disk, so it measured 501 in SQL while the server reported 1. ### The open question answered: `project_overview.file_health` carried nothing `FileResolution` is `path/refs/resolved/no_candidate/internal_missed/unresolved_qualified/unresolved_bare` — no truncation field. Demonstrated: a truncated file reports `{"path":"src/other.rs","refs":3,"resolved":3,"internal_missed":0}`, a perfectly healthy-looking row. The new mechanism names that exact path in `partial_sources` in the same reply. ### Scope, stated rather than implied - **Package-only today.** `parse_with_plugin` returns `Parse::Ok(e, Vec::new())` under the comment "NO DIAGNOSTIC CHANNEL EXISTS ON THIS ARM"; the only non-empty producer is `packages::facts_diagnostics`, reachable solely from `TaskSource::Package`. A builtin extractor cannot produce a truncated tree — but "all parsers become plugins" (#75/#84) makes every user a package user, and this repo's own index already carries one. - **Resources bypass the grader** — `read_resource` serves the same data ungraded. Deliberate: both issues are about tool reports. Worth a follow-up. - **`read_code` gets the block too.** Its bytes are unaffected by truncation; the uniform rule is the price of "no per-tool allowlist", and the wording was softened accordingly. - **False-positive shape:** a snippet whose *entire* text equals a partial file's path would match. No instance exists in this repo, but it is a shape, not an impossibility. Cost: ~0.06–0.11 ms per call (0.252/0.256 → 0.314/0.364 ms, isolated release, 300 calls). Gates: fmt, clippy native **and** `x86_64-pc-windows-gnu`, rustdoc, `cargo test --workspace` **2849 passed / 0 failed**, daemon-leg mcp-server **555 passed / 0 failed** (proving the block crosses the real RPC wire), corpus ratchet unmoved at `534084b856c22566c48e386bc41ed67e`, `precision_gate` 7/7.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#101
No description provided.