C# generic invocation records the type-argument list inside the ref name, so every generic call site is invisible — and both denominators report an unearned zero #172

Closed
opened 2026-09-06 02:38:34 +02:00 by buildagent · 3 comments
Member

Found while authoring hand-verified benchmark questions for #51. Traced to source and reproduced on a purpose-built fixture through the real MCP server.

Measured

Four-line fixture, one class, four call sites:

call site ref_count
Target.PlainBare(); 1
Target.GenericBare<int>(); 0
field.Plain(); 1
field.CastIt<string>(); 0

resolution_gaps names the orphan rows: GenericBare<int> and CastIt<string>.

Those are not symbol names. No symbol in any index can ever bear them, because the declaration is GenericBare and the type arguments are at the call site. So the ref is not merely unresolved — it is unresolvable by construction, against a name that does not exist.

On the pinned cs-dapper corpus this hides 3 of 3 call sites of Extensions.CastResult (Dapper/SqlMapper.Async.cs:1098, :1124, :1146, each ….CastResult<DbDataReader, IDataReader>()). The lines are walked: the other callee on those same three lines, ExecuteWrappedReaderImplAsync, has ref_count: 6 covering exactly 1098/1124/1146.

Mechanism (read at source)

crates/plugins/src/csharp.rs::emit_call, the member_access_expression arm, takes node_text(child_by_field_name("name")) verbatim. When that child is a generic_name node, the <…> comes with it.

head_type_name, roughly twenty lines below in the same file, already knows to unwrap generic_name. The knowledge is present; this call path does not use it.

The part that makes this a disclosure defect and not only a recall one

Because the recorded name matches nothing, both denominators report zero:

ref_count: 0
name_fallback_count: 0
find_callers(...) -> results: [], confidence: { page_name_fallback: 0 }

This project ships name_fallback_count: 0 as an earned zero — the docs say so explicitly, and project_overview.count_basis exists to measure which zeros are vacuous. Here the zero is structural, not earned: it means "nothing bears this name", and the reason nothing bears it is that we invented the name. An agent reading ref_count: 0, name_fallback_count: 0 on a public C# method concludes it is dead code. It is called three times.

Nothing anywhere discloses this. resolution_gaps holds the evidence — it prints the malformed name — but no caller-side tool carries a pointer to it.

Why existing gates could not see it

  • precision_gate grades phantoms (phantom_count == 0) — wrong rows returned. This returns no row, which that gate is blind to by design.
  • The csharp plugin's own extractor tests assert emitted refs for the shapes they cover; a generic invocation is not among them, so the malformed name is never compared to a symbol name.
  • corpus_ratchet pins refs and resolved as counts. A ref that is emitted and never resolves keeps both counts stable, so the ratchet is green.
  • corpus_stage's rule. dimensions only see binds that happen. A bind that cannot happen contributes to no rule.

Repro

A four-line C# file with PlainBare, GenericBare<T>, Plain and CastIt<T> declared and each called once, indexed through the MCP server, then search_symbols on each name and resolution_gaps on the project. Or on the pinned corpus: find_callers on Extensions.CastResult (Dapper/Extensions.cs:12, the sole declaration, no overloads) returns an empty page while rg -nw CastResult finds three call sites.

What must NOT be done to make this pass

  • Do not strip <…> with a string operation. head_type_name already unwraps the generic_name node correctly; a second, textual answer to "what is this name" is how two spellings of one fact drift apart. Use the node.
  • Do not fix only member_access_expression. The same child_by_field_name("name") shape is likely on other arms and in other languages with explicit type arguments (TypeScript f<T>(), Rust turbofish). Whatever is done should rest on the structural fact — a name node may be a generic name — not on this one arm. A fix that lands should say which arms were audited and which were not.
  • Do not resolve the malformed name by fuzzy prefix. That trades a silent miss for a phantom, and phantoms are the one thing precision_gate does gate.
  • Do not treat the recall change alone as the success criterion. The disclosure half is separable and arguably more urgent: until the name is fixed, name_fallback_count: 0 on these symbols is an unearned zero, and the honest interim behaviour is to say so rather than to ship it beside earned ones.

Measured vs inferred

The four fixture readings, the two orphan names from resolution_gaps, ExecuteWrappedReaderImplAsync's ref_count: 6, and the three dapper call sites are all measured. That other arms and other languages share the shape is inferred from the code pattern and is not verified — it is stated as an audit to run, not as a finding.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

Found while authoring hand-verified benchmark questions for #51. Traced to source and reproduced on a purpose-built fixture through the real MCP server. ## Measured Four-line fixture, one class, four call sites: | call site | `ref_count` | |---|---| | `Target.PlainBare();` | **1** | | `Target.GenericBare<int>();` | **0** | | `field.Plain();` | **1** | | `field.CastIt<string>();` | **0** | `resolution_gaps` names the orphan rows: **`GenericBare<int>`** and **`CastIt<string>`**. Those are not symbol names. No symbol in any index can ever bear them, because the declaration is `GenericBare` and the type arguments are at the call site. So the ref is not merely unresolved — it is unresolvable by construction, against a name that does not exist. On the pinned `cs-dapper` corpus this hides **3 of 3** call sites of `Extensions.CastResult` (`Dapper/SqlMapper.Async.cs:1098`, `:1124`, `:1146`, each `….CastResult<DbDataReader, IDataReader>()`). The lines *are* walked: the other callee on those same three lines, `ExecuteWrappedReaderImplAsync`, has `ref_count: 6` covering exactly 1098/1124/1146. ## Mechanism (read at source) `crates/plugins/src/csharp.rs::emit_call`, the `member_access_expression` arm, takes `node_text(child_by_field_name("name"))` **verbatim**. When that child is a `generic_name` node, the `<…>` comes with it. `head_type_name`, roughly twenty lines below in the same file, **already knows to unwrap `generic_name`**. The knowledge is present; this call path does not use it. ## The part that makes this a disclosure defect and not only a recall one Because the recorded name matches nothing, **both denominators report zero**: ``` ref_count: 0 name_fallback_count: 0 find_callers(...) -> results: [], confidence: { page_name_fallback: 0 } ``` This project ships `name_fallback_count: 0` as an **earned** zero — the docs say so explicitly, and `project_overview.count_basis` exists to measure which zeros are vacuous. Here the zero is **structural, not earned**: it means "nothing bears this name", and the reason nothing bears it is that we invented the name. An agent reading `ref_count: 0, name_fallback_count: 0` on a `public` C# method concludes it is dead code. It is called three times. **Nothing anywhere discloses this.** `resolution_gaps` holds the evidence — it prints the malformed name — but no caller-side tool carries a pointer to it. ## Why existing gates could not see it - `precision_gate` grades **phantoms** (`phantom_count == 0`) — wrong rows returned. This returns *no* row, which that gate is blind to by design. - The csharp plugin's own extractor tests assert emitted refs for the shapes they cover; a generic invocation is not among them, so the malformed name is never compared to a symbol name. - `corpus_ratchet` pins `refs` and `resolved` as counts. A ref that is emitted and never resolves keeps both counts stable, so the ratchet is green. - `corpus_stage`'s `rule.` dimensions only see binds that happen. A bind that cannot happen contributes to no rule. ## Repro A four-line C# file with `PlainBare`, `GenericBare<T>`, `Plain` and `CastIt<T>` declared and each called once, indexed through the MCP server, then `search_symbols` on each name and `resolution_gaps` on the project. Or on the pinned corpus: `find_callers` on `Extensions.CastResult` (`Dapper/Extensions.cs:12`, the sole declaration, no overloads) returns an empty page while `rg -nw CastResult` finds three call sites. ## What must NOT be done to make this pass - **Do not strip `<…>` with a string operation.** `head_type_name` already unwraps the `generic_name` node correctly; a second, textual answer to "what is this name" is how two spellings of one fact drift apart. Use the node. - **Do not fix only `member_access_expression`.** The same `child_by_field_name("name")` shape is likely on other arms and in other languages with explicit type arguments (TypeScript `f<T>()`, Rust turbofish). Whatever is done should rest on the structural fact — a name node may be a generic name — not on this one arm. A fix that lands should say which arms were audited and which were not. - **Do not resolve the malformed name by fuzzy prefix.** That trades a silent miss for a phantom, and phantoms are the one thing `precision_gate` does gate. - **Do not treat the recall change alone as the success criterion.** The disclosure half is separable and arguably more urgent: until the name is fixed, `name_fallback_count: 0` on these symbols is an unearned zero, and the honest interim behaviour is to say so rather than to ship it beside earned ones. ## Measured vs inferred The four fixture readings, the two orphan names from `resolution_gaps`, `ExecuteWrappedReaderImplAsync`'s `ref_count: 6`, and the three dapper call sites are all measured. That other arms and other languages share the shape is **inferred from the code pattern and is not verified** — it is stated as an audit to run, not as a finding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

FIXED at the root — and the corpus delta is NOT what it looks like. Read the finding before blessing anything.

The fix

One free helper in crates/plugins/src/csharp.rs, name_identifier(node) -> Option<Node>, resting on the structural fact the issue named: a name-position node may be a generic_name. It unwraps to the identifier leaf via the NODE, never a string operation. head_type_name and emit_import_ref were rewritten to call it instead of carrying their own copies — three spellings of one fact collapsed to one.

The enumeration the issue asked for: all 23 child_by_field_name("name") sites

The issue named ONE arm. Measured on a fixture, four were defective.

line context generic_name possible? verdict
1236 emit_call member_access_expression yes FIXED — the reported defect
1694 emit_member_binding (o?.M<T>()) yes FIXED — not named in the issue
1772 emit_receiver_type_refs_d member-access leaf yes FIXED — not named in the issue
1249 emit_call qualified_name in principle FIXED (hardened) — measured: tree-sitter-c-sharp parses every invocation callee as member_access_expression, so this arm never fired
1815 emit_receiver_type_refs_d qualified_name leaf yes FIXED (hardened), not reachable in fixtures
860 emit_type_ref_d qualified_name leaf yes already structurally correct (recurses into the generic_name arm) — see R1
1176 emit_import_ref yes was correct via an inline copy; now uses the shared helper
1296 / 1300 head_type_name yes already correct; folded into the helper
1991 / 1998 csharp_name_chain_text::is_name_chain yes, but requires identifier deliberately conservative — see R2
184, 202, 210, 257, 283, 314, 382, 427, 944, 1124, 1362, 1488, 1504 params, declarators, properties/events, catch, type params, lambda no (C# properties and events cannot be generic; a declarator name is a plain identifier) unchanged

Malformed names actually observed before the fix: GenericBare<int>, CastIt<string>, Chained<int>, Opt<int>, Make<int>, Sub<int>.

Cross-language audit — MEASURED with fixtures through all_plugins(), not reasoned

  • TypeScript — CLEAN. (call_expression function: (identifier) type_arguments: (type_arguments …)). Type args are a sibling field. Refs come out call "f", method_call "m", type "Box".
  • Rust — CLEAN for names. f::<u32>() → (generic_function function: (identifier) type_arguments: …) → call "f"; x.m::<u32>() → method_call "m". But see R4.
  • PHP — CLEAN, no generics in the grammar. Python / JavaScript / Ruby — CLEAN, no call-site type arguments.

A regression sentinel for TS + Rust ships in the new test file, so "clean" is graded rather than asserted.

Residuals, NAMED (all outside "the ref name"; none fixed)

  • R1 emit_type_ref_d's qualified_name arm recovers the right NAME for N.M.Box<int> but drops the qualifier — emits qualified=false where non-generic N.M.Box emits qualified=true qualifier="N.M". A fidelity asymmetry, not an unmatchable name; changing it moves resolution.
  • R2 csharp_name_chain_text refuses a chain whose leaf is a generic_name, so A.B<int>.C() gets qualifier: None. Conservative by choice — A.B<int> as a qualifier would be the same unmatchable-string class one field over.
  • R3 RawImport.module still carries type args: using Alias = …List<int>; stores module: "System.Collections.Generic.List<int>". The import REF name is clean; the module STRING is not.
  • R4 — different file, not touched. Rust Vec::<u8>::new() emits call name="new" qualified=true qualifier="Vec::<u8>". Name clean, qualifier carries the turbofish — the same unmatchable-by-construction class, in the qualifier field. rust.rs belongs to another lane; reporting, not touching. Worth its own issue.

Mutations — each run, real RED

  1. name_identifier returns Some(node) unconditionally → 4 of 5 RED: expected a method_call ref named exactly "GenericBare"; got [… "GenericBare<int>" …], and ref names carrying type arguments cannot match any symbol: [("method_call","GenericBare<int>"), ("method_call","CastIt<string>"), ("read","Opt<int>"), ("method_call","Chained<int>"), ("read","Make<int>"), ("read","Sub<int>")].
  2. Drop the unwrap from only the emit_call member-access arm → RED with exactly the discriminating residue: GenericBare<int>, CastIt<string>, Chained<int> — while Opt/Make/Sub stay clean. That is why the member-binding and method-group shapes are asserted separately.
  3. Textual <…> strip, keeping generic_name as the span node → every NAME assertion passes; only the span test fails (span must cover the identifier only: left: 14 right: 6). That test is the guard over "use the node, not a string operation" — without it, the forbidden fix passes.
  4. Anti-vacuity of the cross-language test (rename the TS/Rust callee) → RED printing the real extracted refs.

All restores cp + md5-verified + touched. No git checkout, no pkill -f.


THE FINDING — the delta is +33 correct and +34 pre-existing over-binding, not +67 recall

Two release binaries were built (pre-fix from a saved copy, post-fix), cs-dapper indexed with each, and the binds were read at source, not the delta. 67 new resolved sites, 0 lost.

The issue's headline is confirmed exactly: Dapper/SqlMapper.Async.cs:1098, :1124, :1146 → CastResult @ Dapper/Extensions.cs:12. 3 of 3, previously ref_count: 0.

But only 33 of the 67 are correct.

  • Correct (33): GetNullableValue ×18 (unique extension method), CastResult ×3, Link ×3, GetFactory ×4, QueryFirstOrDefault ×2, QueryFirstOrDefaultAsync ×2, DetermineTableName ×1.
  • Wrong (34): benchmark methods binding their own generic call to themselves (Benchmarks.RepoDB.cs:36 _connection.Query<Post>(…) → Benchmarks.RepoDB.cs:32 public Post Query()), and same-directory test overrides (MiscTests.cs:1111 connection.ExecuteScalar<int>(…) → WrappedReaderTests.cs:47 public override object ExecuteScalar(), while SqlMapper.cs:598 ExecuteScalar<T> exists).

These are NOT new phantoms — the class was proved PRE-EXISTING, bind-for-bind, by running the old binary:

name → target OLD NEW
Add → LegacyTests.cs:58 37 41
ExecuteScalar → WrappedReaderTests.cs:47 3 10
Query → benchmark self-binds 2 8

The resolver's tier-2 same-file and I023 same-dir method-call rules already fire on non-generic C# calls. The malformed name had been acting as an accidental precision filter. Fixing it removes the filter and exposes over-binding that was always there. precision_gate still reports phantoms=0 — but its oracle is its own fixture set and does not grade cs-dapper, which is exactly the blind spot this measurement fills.

The name fix is right and unavoidable: you cannot ship a ref name no symbol can bear. But the corpus delta must be blessed as "+33 correct, +34 pre-existing over-binding exposed", not as "+67 recall", and the same-file / same-dir gates for C# method calls are the obvious follow-up (a resolver file, not this one).

Corpus — UNBLESSED, baseline byte-identical

COSI_CORPUS_DIR=… COSI_CORPUS_REQUIRE=1 cargo test --release -p code-index-indexer --test corpus_ratchet → FAILED as intended, and only cs-dapper moved (six other repos byte-identical — the cross-language no-collateral evidence):

cs-dapper: resolved             3744 -> 3811 (+67)
cs-dapper: edges                2682 -> 2710 (+28)
cs-dapper: resolved_method_call  445 ->  509 (+64)
cs-dapper: resolved_type         841 ->  844  (+3)

tests/corpus/baseline.json not touched. Per this lane's brief a record that moves is a finding to report, not something to bless — and in this case the reason to leave it is stronger than the convention: blessing +67 as recall would record a number that is half wrong.

Gates

cargo fmt -p code-index-plugins -- --check 0 · clippy -p code-index-plugins --all-targets -D warnings 0 · cargo test -p code-index-plugins --no-fail-fast 0 · precision_gate 0 — 7/7, phantoms=0 in every language, recall 1.000 across all seven · indexer resolver/receiver_phantom/multilang/import_refs/visibility 0 · daemon correctness/lang_e2e/name_fallback_parity_e2e/gap_fill 0 · RUSTDOCFLAGS="-D warnings" cargo doc 0 · doc_citation_gate 0.

Process disclosure against ourselves

rustfmt --edition 2024 was run once on csharp.rs. The workspace is edition 2021, so it silently reformatted unrelated pre-existing code (import ordering, assert! layout). cargo fmt -p … --check caught it and re-running with --edition 2021 fixed it; the final git diff of csharp.rs contains only the #172 change. Worth recording: if you reach for rustfmt directly to avoid touching a sibling lane's file, pass the workspace edition.

Dogfood finding about our own tools

The required enumeration — every child_by_field_name("name") in one file — hit a real gap. search_text returned matches_in_file: {count: 23, lines: [20 of them], lines_truncated: true}. The disclosure is honest, but there is no way to page occurrences WITHIN a file: cursor pages files, and pinning path_glob to the single file does not help. Fell back to grep -n for the last 3. Two independent sub-lanes hit the identical wall on the identical audit shape today. A matches_in_file cursor would close it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

## FIXED at the root — and the corpus delta is NOT what it looks like. Read the finding before blessing anything. ### The fix One free helper in `crates/plugins/src/csharp.rs`, `name_identifier(node) -> Option<Node>`, resting on the structural fact the issue named: **a name-position node may be a `generic_name`**. It unwraps to the identifier leaf via the NODE, never a string operation. `head_type_name` and `emit_import_ref` were rewritten to call it instead of carrying their own copies — three spellings of one fact collapsed to one. ### The enumeration the issue asked for: all 23 `child_by_field_name("name")` sites The issue named ONE arm. Measured on a fixture, **four** were defective. | line | context | generic_name possible? | verdict | |---|---|---|---| | 1236 | `emit_call` `member_access_expression` | yes | **FIXED — the reported defect** | | 1694 | `emit_member_binding` (`o?.M<T>()`) | yes | **FIXED — not named in the issue** | | 1772 | `emit_receiver_type_refs_d` member-access leaf | yes | **FIXED — not named in the issue** | | 1249 | `emit_call` `qualified_name` | in principle | **FIXED (hardened)** — measured: tree-sitter-c-sharp parses every invocation callee as `member_access_expression`, so this arm never fired | | 1815 | `emit_receiver_type_refs_d` `qualified_name` leaf | yes | **FIXED (hardened)**, not reachable in fixtures | | 860 | `emit_type_ref_d` `qualified_name` leaf | yes | already structurally correct (recurses into the `generic_name` arm) — see R1 | | 1176 | `emit_import_ref` | yes | was correct via an inline copy; now uses the shared helper | | 1296 / 1300 | `head_type_name` | yes | already correct; folded into the helper | | 1991 / 1998 | `csharp_name_chain_text::is_name_chain` | yes, but requires `identifier` | deliberately conservative — see R2 | | 184, 202, 210, 257, 283, 314, 382, 427, 944, 1124, 1362, 1488, 1504 | params, declarators, properties/events, catch, type params, lambda | **no** (C# properties and events cannot be generic; a declarator name is a plain identifier) | unchanged | Malformed names actually observed before the fix: `GenericBare<int>`, `CastIt<string>`, `Chained<int>`, `Opt<int>`, `Make<int>`, `Sub<int>`. ### Cross-language audit — MEASURED with fixtures through `all_plugins()`, not reasoned - **TypeScript — CLEAN.** `(call_expression function: (identifier) type_arguments: (type_arguments …))`. Type args are a *sibling field*. Refs come out `call "f"`, `method_call "m"`, `type "Box"`. - **Rust — CLEAN for names.** `f::<u32>()` → `(generic_function function: (identifier) type_arguments: …)` → `call "f"`; `x.m::<u32>()` → `method_call "m"`. But see **R4**. - **PHP — CLEAN**, no generics in the grammar. **Python / JavaScript / Ruby — CLEAN**, no call-site type arguments. A regression sentinel for TS + Rust ships in the new test file, so "clean" is graded rather than asserted. ### Residuals, NAMED (all outside "the ref name"; none fixed) - **R1** `emit_type_ref_d`'s `qualified_name` arm recovers the right NAME for `N.M.Box<int>` but drops the qualifier — emits `qualified=false` where non-generic `N.M.Box` emits `qualified=true qualifier="N.M"`. A fidelity asymmetry, not an unmatchable name; changing it moves resolution. - **R2** `csharp_name_chain_text` refuses a chain whose leaf is a `generic_name`, so `A.B<int>.C()` gets `qualifier: None`. Conservative by choice — `A.B<int>` as a qualifier would be the same unmatchable-string class one field over. - **R3** `RawImport.module` still carries type args: `using Alias = …List<int>;` stores `module: "System.Collections.Generic.List<int>"`. The import REF name is clean; the module STRING is not. - **R4 — different file, not touched.** Rust `Vec::<u8>::new()` emits `call name="new" qualified=true qualifier="Vec::<u8>"`. Name clean, **qualifier carries the turbofish** — the same unmatchable-by-construction class, in the qualifier field. `rust.rs` belongs to another lane; reporting, not touching. **Worth its own issue.** ### Mutations — each run, real RED 1. `name_identifier` returns `Some(node)` unconditionally → 4 of 5 RED: `expected a method_call ref named exactly "GenericBare"; got [… "GenericBare<int>" …]`, and `ref names carrying type arguments cannot match any symbol: [("method_call","GenericBare<int>"), ("method_call","CastIt<string>"), ("read","Opt<int>"), ("method_call","Chained<int>"), ("read","Make<int>"), ("read","Sub<int>")]`. 2. Drop the unwrap from **only** the `emit_call` member-access arm → RED with exactly the discriminating residue: `GenericBare<int>`, `CastIt<string>`, `Chained<int>` — while `Opt`/`Make`/`Sub` stay clean. That is why the member-binding and method-group shapes are asserted separately. 3. **Textual `<…>` strip, keeping `generic_name` as the span node** → every NAME assertion passes; only the span test fails (`span must cover the identifier only: left: 14 right: 6`). That test is the guard over "use the node, not a string operation" — without it, the forbidden fix passes. 4. Anti-vacuity of the cross-language test (rename the TS/Rust callee) → RED printing the real extracted refs. All restores `cp` + md5-verified + `touch`ed. No `git checkout`, no `pkill -f`. --- ## THE FINDING — the delta is +33 correct and +34 pre-existing over-binding, not +67 recall Two release binaries were built (pre-fix from a saved copy, post-fix), `cs-dapper` indexed with each, and **the binds were read at source, not the delta**. 67 new resolved sites, 0 lost. **The issue's headline is confirmed exactly**: `Dapper/SqlMapper.Async.cs:1098, :1124, :1146` → `CastResult @ Dapper/Extensions.cs:12`. 3 of 3, previously `ref_count: 0`. **But only 33 of the 67 are correct.** - **Correct (33):** `GetNullableValue` ×18 (unique extension method), `CastResult` ×3, `Link` ×3, `GetFactory` ×4, `QueryFirstOrDefault` ×2, `QueryFirstOrDefaultAsync` ×2, `DetermineTableName` ×1. - **Wrong (34):** benchmark methods binding their own generic call to themselves (`Benchmarks.RepoDB.cs:36 _connection.Query<Post>(…)` → `Benchmarks.RepoDB.cs:32 public Post Query()`), and same-directory test overrides (`MiscTests.cs:1111 connection.ExecuteScalar<int>(…)` → `WrappedReaderTests.cs:47 public override object ExecuteScalar()`, while `SqlMapper.cs:598 ExecuteScalar<T>` exists). **These are NOT new phantoms — the class was proved PRE-EXISTING, bind-for-bind, by running the old binary:** | name → target | OLD | NEW | |---|---|---| | `Add` → `LegacyTests.cs:58` | **37** | 41 | | `ExecuteScalar` → `WrappedReaderTests.cs:47` | **3** | 10 | | `Query` → benchmark self-binds | **2** | 8 | The resolver's tier-2 same-file and I023 same-dir method-call rules already fire on non-generic C# calls. **The malformed name had been acting as an accidental precision filter.** Fixing it removes the filter and exposes over-binding that was always there. `precision_gate` still reports `phantoms=0` — but its oracle is its own fixture set and does not grade `cs-dapper`, which is exactly the blind spot this measurement fills. The name fix is right and unavoidable: you cannot ship a ref name no symbol can bear. But **the corpus delta must be blessed as "+33 correct, +34 pre-existing over-binding exposed", not as "+67 recall"**, and the same-file / same-dir gates for C# method calls are the obvious follow-up (a resolver file, not this one). ### Corpus — UNBLESSED, baseline byte-identical `COSI_CORPUS_DIR=… COSI_CORPUS_REQUIRE=1 cargo test --release -p code-index-indexer --test corpus_ratchet` → FAILED as intended, and **only `cs-dapper` moved** (six other repos byte-identical — the cross-language no-collateral evidence): ``` cs-dapper: resolved 3744 -> 3811 (+67) cs-dapper: edges 2682 -> 2710 (+28) cs-dapper: resolved_method_call 445 -> 509 (+64) cs-dapper: resolved_type 841 -> 844 (+3) ``` `tests/corpus/baseline.json` not touched. Per this lane's brief a record that moves is a finding to report, not something to bless — and in this case the reason to leave it is stronger than the convention: **blessing +67 as recall would record a number that is half wrong.** ### Gates `cargo fmt -p code-index-plugins -- --check` **0** · `clippy -p code-index-plugins --all-targets -D warnings` **0** · `cargo test -p code-index-plugins --no-fail-fast` **0** · `precision_gate` **0 — 7/7, phantoms=0 in every language, recall 1.000 across all seven** · indexer `resolver`/`receiver_phantom`/`multilang`/`import_refs`/`visibility` **0** · daemon `correctness`/`lang_e2e`/`name_fallback_parity_e2e`/`gap_fill` **0** · `RUSTDOCFLAGS="-D warnings" cargo doc` **0** · `doc_citation_gate` **0**. ### Process disclosure against ourselves `rustfmt --edition 2024` was run once on `csharp.rs`. **The workspace is edition 2021**, so it silently reformatted unrelated pre-existing code (import ordering, `assert!` layout). `cargo fmt -p … --check` caught it and re-running with `--edition 2021` fixed it; the final `git diff` of `csharp.rs` contains only the #172 change. Worth recording: if you reach for `rustfmt` directly to avoid touching a sibling lane's file, pass the workspace edition. ### Dogfood finding about our own tools The required enumeration — *every* `child_by_field_name("name")` in one file — hit a real gap. `search_text` returned `matches_in_file: {count: 23, lines: [20 of them], lines_truncated: true}`. The disclosure is honest, but **there is no way to page occurrences WITHIN a file**: `cursor` pages files, and pinning `path_glob` to the single file does not help. Fell back to `grep -n` for the last 3. Two independent sub-lanes hit the identical wall on the identical audit shape today. A `matches_in_file` cursor would close it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

Correction and full attribution of the +67, measured independently. One claim I published above was WRONG.

Re-measured from scratch (two release binaries differing only in csharp.rs, cs-dapper @ 72a54c475f indexed with each, refs joined row-for-row on (path, line, col, kind, occurrence-index)).

First, a methodology trap worth recording. My initial join on (path, line, col) alone reported 269 newly resolved, 202 lost — alarming and completely false. 235 positions in this repo carry more than one ref, so the join fanned out. With the occurrence index added: 22429 of 22432 refs match 1:1, 64 newly resolved, 0 lost, 0 retargeted, 655 names corrected. The 3 unmatched are type Link refs whose span moved. 64 + 3 = the +67. A delta measured with a non-unique key is not a measurement.

The 67, attributed per rule — and the per-rule numbers reconcile exactly with stage-baseline

resolved_by Δ correct wrong what
tier1a_unique_own_file +7 5 2 ✅ GetFactory ×4, DetermineTableName ×1 · ❌ FindObject, GetObjectByKey
tier1b_same_directory +23 21 2 ✅ GetNullableValue ×18, CastResult ×3 · ❌ FindObject, GetObjectByKey
tier2_same_file +21 4 17 ✅ QueryFirstOrDefault ×2, QueryFirstOrDefaultAsync ×2 · ❌ Query ×6, QueryFirstOrDefault* ×3, ExecuteQuery ×2, Fetch ×2, Get ×2, Read ×2
tier3_import_boost +13 0 13 ❌ ExecuteScalar ×7, Add ×4, Fetch ×2
tier1q_pass1 +3 3 0 ✅ Link ×3
+67 33 34

7+23+21+13+3 = 67, matching stage-baseline's five rule. deltas line for line.

THE CORRECTION

I wrote above, and repeated it in my lane report:

stage-baseline independently corroborates it (tier1b_same_directory +23, tier2_same_file +21).

That is wrong, and it points at the wrong tier. tier1b_same_directory is 21 of 23 CORRECT — it is where GetNullableValue ×18 and CastResult ×3 landed, i.e. the best binds in the whole change. I reached for the two largest stage deltas and read them as the two largest problems. 23 + 21 = 44 ≠ 34 should have stopped me; the stage deltas partition all 67 by rule, not the wrong subset.

The wrong binds concentrate in tier2_same_file (17/21 wrong) and tier3_import_boost (13/13 wrong) — 30 of the 34. That is the sharp, actionable finding, and it is a different follow-up from the one I filed above.

Are they phantoms? Yes. Say it plainly.

Under the old code these 34 refs did not resolve at all — the name Query<Post> matched nothing. They are new resolutions and they are wrong. This change admits 34 wrong binds. Read at source:

  • benchmarks/…/Benchmarks.RepoDB.cs:36 _connection.Query<Post>(i) (RepoDB's extension method on IDbConnection) → binds to Benchmarks.RepoDB.cs:32 public Post Query(), the enclosing benchmark method itself. Same for :43, :50, :57.
  • tests/Dapper.Tests/MiscTests.cs:1111 connection.ExecuteScalar<int>("select 123") → binds to tests/Dapper.Tests/WrappedReaderTests.cs:47 public override object ExecuteScalar(), a zero-arg DbCommand override in a test double — while the correct target Dapper/SqlMapper.cs:598 public static T? ExecuteScalar<T>(this IDbConnection …) is in the index.
  • benchmarks/…/Benchmarks.RepoDB.cs:23 DbSettingMapper.Add<SqlConnection>(dbSetting, true) (RepoDB static) → binds to LegacyTests.cs:58 public void Add(Action<int>, string) in private class Tests : List<Test>, a different file and an unrelated class.
  • Dapper.Rainbow/Database.cs:376 _connection.QueryFirstOrDefault<T>(...) → binds to :375, the method whose own body that call is.

The mitigating context, and it is context and not an excuse: the rule pre-dates this fix and already produced this exact shape for non-generic calls. Measured old → new for the same targets: Add → LegacyTests.cs:58 37 → 41, ExecuteScalar → WrappedReaderTests.cs:47 3 → 10. But Query → Benchmarks.RepoDB.cs:32 is 0 → 4 — that target had no binds at all before, so "pre-existing" is true of the rule and not of all three exemplars I cited. The 34 rows are all new.

Why precision_gate stays 7/7 with phantoms=0 — and this is the biggest finding here

Its C# population is 3 files, 66 lines, 4 probes, and contains ZERO generic call sites. grep -cE '<[A-Za-z_]+>\s*\(' tests/fixtures/csharp/project/*.cs → 0, 0, 0. The entire #172 defect class is outside the gate's population by construction: it cannot see a <T> call because its fixture has none.

grep -c COSI_CORPUS crates/daemon/tests/precision_gate.rs → 0. It never indexes any corpus repo. Its whole universe:

lang probes fixture files
csharp 4 6
javascript 4 3
php / ruby 5 7 / 13
typescript 7 8
python 9 8
rust 13 9

And a phantom can only be scored against a declared forbid_resolved decoy — a wrong bind to any symbol nobody thought to list as a decoy is invisible even inside the fixture.

So phantom_count == 0 is a true statement about ~50 probes over hand-written fixtures. It is not, and has never been, a statement about the 3811 binds on cs-dapper. The wording "the one thing this project gates absolutely" overstates the scope of the gate, in my earlier comment and in the issue text. The real gate against this class would be a corpus-scale phantom oracle, and it does not exist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

## Correction and full attribution of the +67, measured independently. One claim I published above was WRONG. Re-measured from scratch (two release binaries differing only in `csharp.rs`, `cs-dapper` @ `72a54c475f` indexed with each, refs joined row-for-row on `(path, line, col, kind, occurrence-index)`). **First, a methodology trap worth recording.** My initial join on `(path, line, col)` alone reported *269 newly resolved, 202 lost* — alarming and completely false. 235 positions in this repo carry more than one ref, so the join fanned out. With the occurrence index added: **22429 of 22432 refs match 1:1, 64 newly resolved, 0 lost, 0 retargeted, 655 names corrected.** The 3 unmatched are `type Link` refs whose span moved. 64 + 3 = the +67. A delta measured with a non-unique key is not a measurement. ### The 67, attributed per rule — and the per-rule numbers reconcile exactly with `stage-baseline` | `resolved_by` | Δ | correct | wrong | what | |---|---|---|---|---| | `tier1a_unique_own_file` | +7 | **5** | 2 | ✅ `GetFactory` ×4, `DetermineTableName` ×1 · ❌ `FindObject`, `GetObjectByKey` | | `tier1b_same_directory` | +23 | **21** | 2 | ✅ `GetNullableValue` ×18, `CastResult` ×3 · ❌ `FindObject`, `GetObjectByKey` | | `tier2_same_file` | +21 | **4** | **17** | ✅ `QueryFirstOrDefault` ×2, `QueryFirstOrDefaultAsync` ×2 · ❌ `Query` ×6, `QueryFirstOrDefault*` ×3, `ExecuteQuery` ×2, `Fetch` ×2, `Get` ×2, `Read` ×2 | | `tier3_import_boost` | +13 | **0** | **13** | ❌ `ExecuteScalar` ×7, `Add` ×4, `Fetch` ×2 | | `tier1q_pass1` | +3 | **3** | 0 | ✅ `Link` ×3 | | | **+67** | **33** | **34** | | 7+23+21+13+3 = 67, matching `stage-baseline`'s five `rule.` deltas line for line. ### THE CORRECTION I wrote above, and repeated it in my lane report: > `stage-baseline` independently corroborates it (`tier1b_same_directory +23`, `tier2_same_file +21`). **That is wrong, and it points at the wrong tier.** `tier1b_same_directory` is **21 of 23 CORRECT** — it is where `GetNullableValue` ×18 and `CastResult` ×3 landed, i.e. the best binds in the whole change. I reached for the two largest stage deltas and read them as the two largest problems. 23 + 21 = 44 ≠ 34 should have stopped me; the stage deltas partition **all 67** by rule, not the wrong subset. **The wrong binds concentrate in `tier2_same_file` (17/21 wrong) and `tier3_import_boost` (13/13 wrong)** — 30 of the 34. That is the sharp, actionable finding, and it is a different follow-up from the one I filed above. ### Are they phantoms? Yes. Say it plainly. Under the old code these 34 refs did not resolve at all — the name `Query<Post>` matched nothing. **They are new resolutions and they are wrong.** This change admits 34 wrong binds. Read at source: - `benchmarks/…/Benchmarks.RepoDB.cs:36` `_connection.Query<Post>(i)` (RepoDB's *extension method on IDbConnection*) → binds to `Benchmarks.RepoDB.cs:32 public Post Query()`, **the enclosing benchmark method itself**. Same for :43, :50, :57. - `tests/Dapper.Tests/MiscTests.cs:1111` `connection.ExecuteScalar<int>("select 123")` → binds to `tests/Dapper.Tests/WrappedReaderTests.cs:47 public override object ExecuteScalar()`, a zero-arg `DbCommand` override in a test double — **while the correct target `Dapper/SqlMapper.cs:598 public static T? ExecuteScalar<T>(this IDbConnection …)` is in the index.** - `benchmarks/…/Benchmarks.RepoDB.cs:23` `DbSettingMapper.Add<SqlConnection>(dbSetting, true)` (RepoDB static) → binds to `LegacyTests.cs:58 public void Add(Action<int>, string)` in `private class Tests : List<Test>`, a different file and an unrelated class. - `Dapper.Rainbow/Database.cs:376` `_connection.QueryFirstOrDefault<T>(...)` → binds to `:375`, **the method whose own body that call is**. The mitigating context, and it is context and not an excuse: the *rule* pre-dates this fix and already produced this exact shape for non-generic calls. Measured old → new for the same targets: `Add → LegacyTests.cs:58` **37 → 41**, `ExecuteScalar → WrappedReaderTests.cs:47` **3 → 10**. But `Query → Benchmarks.RepoDB.cs:32` is **0 → 4** — that target had no binds at all before, so "pre-existing" is true of the rule and **not** of all three exemplars I cited. The 34 rows are all new. ### Why `precision_gate` stays 7/7 with `phantoms=0` — and this is the biggest finding here **Its C# population is 3 files, 66 lines, 4 probes, and contains ZERO generic call sites.** `grep -cE '<[A-Za-z_]+>\s*\(' tests/fixtures/csharp/project/*.cs` → `0, 0, 0`. The entire #172 defect class is outside the gate's population **by construction**: it cannot see a `<T>` call because its fixture has none. `grep -c COSI_CORPUS crates/daemon/tests/precision_gate.rs` → **0**. It never indexes any corpus repo. Its whole universe: | lang | probes | fixture files | |---|---|---| | csharp | 4 | 6 | | javascript | 4 | 3 | | php / ruby | 5 | 7 / 13 | | typescript | 7 | 8 | | python | 9 | 8 | | rust | 13 | 9 | And a phantom can only be scored against a **declared `forbid_resolved` decoy** — a wrong bind to any symbol nobody thought to list as a decoy is invisible even inside the fixture. So `phantom_count == 0` is a true statement about ~50 probes over hand-written fixtures. **It is not, and has never been, a statement about the 3811 binds on `cs-dapper`.** The wording "the one thing this project gates absolutely" overstates the scope of the gate, in my earlier comment and in the issue text. The real gate against this class would be a corpus-scale phantom oracle, and it does not exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

CLOSING — fixed at the root, with the exposed over-binding carried by #189

Close-out lane. Verified on master 552e3a2; code read with this repo's own tools.

On master

crates/plugins/src/csharp.rs:1997 — fn name_identifier(node) -> Option<Node>, with the whole mechanism in its doc. Both emit_call arms route through it now, which is the arm enumeration this issue asked for rather than a one-site patch:

csharp.rs:1233   member_access_expression arm
csharp.rs:1247   qualified_name arm

So a generic invocation no longer records CastIt<string> as the ref name, and the two denominators stop reporting a structural zero.

Graded

crates/plugins/tests/csharp_generic_name.rs — 5 tests, all passing, including:

  • csharp_generic_call_span_excludes_the_type_arguments — the guard that makes the forbidden textual-strip fix fail rather than pass;
  • typescript_and_rust_explicit_type_arguments_stay_out_of_ref_names — the cross-language sentinel.

Runs here, exit codes captured directly (no pipe): cargo test -p code-index-plugins EXIT=0 (270 + 8 + 2 + 5 + 9 + 3 + 3 passed, 0 failed); precision_gate EXIT=0, 7/7 with phantoms=0.

Residual, and it has an owner

Fixing the name exposed 34 wrong binds — a receiver-blind C# method call reaching the wrong target now that the name matches at all. That is recorded in the blessed tests/corpus/baseline.json (blessed at aa5236e with a reason naming the moves, cs-dapper resolved 3744→3811) and carried forward as #189, which stays open. This issue does not need to stay open to hold it.

One correction to the implementing lane's own report

The lane called residual R4 — Rust Vec::<u8>::new() storing qualifier "Vec::<u8>" — "the same unmatchable-by-construction class" and "worth its own issue". That is overstated and no issue should be filed for it: crates/indexer/src/index.rs:5256-5264 already cuts each qualifier segment at < when building anchors, and crates/indexer/tests/resolver.rs:2122 turbofish_qualifier_anchors_like_the_plain_spelling grades it with a decoy. R4 is stored-string fidelity only; it does not cost a bind.

Closing.

## CLOSING — fixed at the root, with the exposed over-binding carried by #189 Close-out lane. Verified on master `552e3a2`; code read with this repo's own tools. ### On master `crates/plugins/src/csharp.rs:1997` — `fn name_identifier(node) -> Option<Node>`, with the whole mechanism in its doc. **Both** `emit_call` arms route through it now, which is the arm enumeration this issue asked for rather than a one-site patch: ``` csharp.rs:1233 member_access_expression arm csharp.rs:1247 qualified_name arm ``` So a generic invocation no longer records `CastIt<string>` as the ref name, and the two denominators stop reporting a structural zero. ### Graded `crates/plugins/tests/csharp_generic_name.rs` — 5 tests, all passing, including: - `csharp_generic_call_span_excludes_the_type_arguments` — the guard that makes the forbidden textual-strip fix fail rather than pass; - `typescript_and_rust_explicit_type_arguments_stay_out_of_ref_names` — the cross-language sentinel. Runs here, exit codes captured directly (no pipe): `cargo test -p code-index-plugins` **EXIT=0** (270 + 8 + 2 + 5 + 9 + 3 + 3 passed, 0 failed); `precision_gate` **EXIT=0**, 7/7 with `phantoms=0`. ### Residual, and it has an owner Fixing the name exposed **34 wrong binds** — a receiver-blind C# method call reaching the wrong target now that the name matches at all. That is recorded in the blessed `tests/corpus/baseline.json` (blessed at `aa5236e` with a reason naming the moves, `cs-dapper resolved 3744→3811`) and carried forward as **#189**, which stays open. This issue does not need to stay open to hold it. ### One correction to the implementing lane's own report The lane called residual **R4** — Rust `Vec::<u8>::new()` storing qualifier `"Vec::<u8>"` — *"the same unmatchable-by-construction class"* and *"worth its own issue"*. That is overstated and **no issue should be filed for it**: `crates/indexer/src/index.rs:5256-5264` already cuts each qualifier segment at `<` when building anchors, and `crates/indexer/tests/resolver.rs:2122 turbofish_qualifier_anchors_like_the_plain_spelling` grades it with a decoy. R4 is stored-string fidelity only; it does not cost a bind. Closing.
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#172
No description provided.