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
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#172
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
ref_countTarget.PlainBare();Target.GenericBare<int>();field.Plain();field.CastIt<string>();resolution_gapsnames the orphan rows:GenericBare<int>andCastIt<string>.Those are not symbol names. No symbol in any index can ever bear them, because the declaration is
GenericBareand 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-dappercorpus this hides 3 of 3 call sites ofExtensions.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, hasref_count: 6covering exactly 1098/1124/1146.Mechanism (read at source)
crates/plugins/src/csharp.rs::emit_call, themember_access_expressionarm, takesnode_text(child_by_field_name("name"))verbatim. When that child is ageneric_namenode, the<…>comes with it.head_type_name, roughly twenty lines below in the same file, already knows to unwrapgeneric_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:
This project ships
name_fallback_count: 0as an earned zero — the docs say so explicitly, andproject_overview.count_basisexists 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 readingref_count: 0, name_fallback_count: 0on apublicC# method concludes it is dead code. It is called three times.Nothing anywhere discloses this.
resolution_gapsholds 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_gategrades phantoms (phantom_count == 0) — wrong rows returned. This returns no row, which that gate is blind to by design.corpus_ratchetpinsrefsandresolvedas counts. A ref that is emitted and never resolves keeps both counts stable, so the ratchet is green.corpus_stage'srule.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>,PlainandCastIt<T>declared and each called once, indexed through the MCP server, thensearch_symbolson each name andresolution_gapson the project. Or on the pinned corpus:find_callersonExtensions.CastResult(Dapper/Extensions.cs:12, the sole declaration, no overloads) returns an empty page whilerg -nw CastResultfinds three call sites.What must NOT be done to make this pass
<…>with a string operation.head_type_namealready unwraps thegeneric_namenode correctly; a second, textual answer to "what is this name" is how two spellings of one fact drift apart. Use the node.member_access_expression. The samechild_by_field_name("name")shape is likely on other arms and in other languages with explicit type arguments (TypeScriptf<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.precision_gatedoes gate.name_fallback_count: 0on 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'sref_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
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 ageneric_name. It unwraps to the identifier leaf via the NODE, never a string operation.head_type_nameandemit_import_refwere 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")sitesThe issue named ONE arm. Measured on a fixture, four were defective.
emit_callmember_access_expressionemit_member_binding(o?.M<T>())emit_receiver_type_refs_dmember-access leafemit_callqualified_namemember_access_expression, so this arm never firedemit_receiver_type_refs_dqualified_nameleafemit_type_ref_dqualified_nameleafgeneric_namearm) — see R1emit_import_refhead_type_namecsharp_name_chain_text::is_name_chainidentifierMalformed 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(call_expression function: (identifier) type_arguments: (type_arguments …)). Type args are a sibling field. Refs come outcall "f",method_call "m",type "Box".f::<u32>()→(generic_function function: (identifier) type_arguments: …)→call "f";x.m::<u32>()→method_call "m". But see R4.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)
emit_type_ref_d'squalified_namearm recovers the right NAME forN.M.Box<int>but drops the qualifier — emitsqualified=falsewhere non-genericN.M.Boxemitsqualified=true qualifier="N.M". A fidelity asymmetry, not an unmatchable name; changing it moves resolution.csharp_name_chain_textrefuses a chain whose leaf is ageneric_name, soA.B<int>.C()getsqualifier: None. Conservative by choice —A.B<int>as a qualifier would be the same unmatchable-string class one field over.RawImport.modulestill carries type args:using Alias = …List<int>;storesmodule: "System.Collections.Generic.List<int>". The import REF name is clean; the module STRING is not.Vec::<u8>::new()emitscall name="new" qualified=true qualifier="Vec::<u8>". Name clean, qualifier carries the turbofish — the same unmatchable-by-construction class, in the qualifier field.rust.rsbelongs to another lane; reporting, not touching. Worth its own issue.Mutations — each run, real RED
name_identifierreturnsSome(node)unconditionally → 4 of 5 RED:expected a method_call ref named exactly "GenericBare"; got [… "GenericBare<int>" …], andref 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>")].emit_callmember-access arm → RED with exactly the discriminating residue:GenericBare<int>,CastIt<string>,Chained<int>— whileOpt/Make/Substay clean. That is why the member-binding and method-group shapes are asserted separately.<…>strip, keepinggeneric_nameas 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.All restores
cp+ md5-verified +touched. Nogit checkout, nopkill -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-dapperindexed 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, previouslyref_count: 0.But only 33 of the 67 are correct.
GetNullableValue×18 (unique extension method),CastResult×3,Link×3,GetFactory×4,QueryFirstOrDefault×2,QueryFirstOrDefaultAsync×2,DetermineTableName×1.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(), whileSqlMapper.cs:598 ExecuteScalar<T>exists).These are NOT new phantoms — the class was proved PRE-EXISTING, bind-for-bind, by running the old binary:
Add→LegacyTests.cs:58ExecuteScalar→WrappedReaderTests.cs:47Query→ benchmark self-bindsThe 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_gatestill reportsphantoms=0— but its oracle is its own fixture set and does not gradecs-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 onlycs-dappermoved (six other repos byte-identical — the cross-language no-collateral evidence):tests/corpus/baseline.jsonnot 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 -- --check0 ·clippy -p code-index-plugins --all-targets -D warnings0 ·cargo test -p code-index-plugins --no-fail-fast0 ·precision_gate0 — 7/7, phantoms=0 in every language, recall 1.000 across all seven · indexerresolver/receiver_phantom/multilang/import_refs/visibility0 · daemoncorrectness/lang_e2e/name_fallback_parity_e2e/gap_fill0 ·RUSTDOCFLAGS="-D warnings" cargo doc0 ·doc_citation_gate0.Process disclosure against ourselves
rustfmt --edition 2024was run once oncsharp.rs. The workspace is edition 2021, so it silently reformatted unrelated pre-existing code (import ordering,assert!layout).cargo fmt -p … --checkcaught it and re-running with--edition 2021fixed it; the finalgit diffofcsharp.rscontains only the #172 change. Worth recording: if you reach forrustfmtdirectly 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_textreturnedmatches_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:cursorpages files, and pinningpath_globto the single file does not help. Fell back togrep -nfor the last 3. Two independent sub-lanes hit the identical wall on the identical audit shape today. Amatches_in_filecursor would close it.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
total: 0with no channel saying candidates were dropped for package-scope ambiguity — the matched_keys half is fixed, this half is not #174Correction 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@72a54c475findexed 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 aretype Linkrefs 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-baselineresolved_bytier1a_unique_own_fileGetFactory×4,DetermineTableName×1 · ❌FindObject,GetObjectByKeytier1b_same_directoryGetNullableValue×18,CastResult×3 · ❌FindObject,GetObjectByKeytier2_same_fileQueryFirstOrDefault×2,QueryFirstOrDefaultAsync×2 · ❌Query×6,QueryFirstOrDefault*×3,ExecuteQuery×2,Fetch×2,Get×2,Read×2tier3_import_boostExecuteScalar×7,Add×4,Fetch×2tier1q_pass1Link×37+23+21+13+3 = 67, matching
stage-baseline's fiverule.deltas line for line.THE CORRECTION
I wrote above, and repeated it in my lane report:
That is wrong, and it points at the wrong tier.
tier1b_same_directoryis 21 of 23 CORRECT — it is whereGetNullableValue×18 andCastResult×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) andtier3_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 toBenchmarks.RepoDB.cs:32 public Post Query(), the enclosing benchmark method itself. Same for :43, :50, :57.tests/Dapper.Tests/MiscTests.cs:1111connection.ExecuteScalar<int>("select 123")→ binds totests/Dapper.Tests/WrappedReaderTests.cs:47 public override object ExecuteScalar(), a zero-argDbCommandoverride in a test double — while the correct targetDapper/SqlMapper.cs:598 public static T? ExecuteScalar<T>(this IDbConnection …)is in the index.benchmarks/…/Benchmarks.RepoDB.cs:23DbSettingMapper.Add<SqlConnection>(dbSetting, true)(RepoDB static) → binds toLegacyTests.cs:58 public void Add(Action<int>, string)inprivate 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:5837 → 41,ExecuteScalar → WrappedReaderTests.cs:473 → 10. ButQuery → Benchmarks.RepoDB.cs:32is 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_gatestays 7/7 withphantoms=0— and this is the biggest finding hereIts 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:And a phantom can only be scored against a declared
forbid_resolveddecoy — a wrong bind to any symbol nobody thought to list as a decoy is invisible even inside the fixture.So
phantom_count == 0is a true statement about ~50 probes over hand-written fixtures. It is not, and has never been, a statement about the 3811 binds oncs-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
phantom_count == 0bounds ~50 hand-written probes, not the corpus, and it is cited across the tree as an absolute guarantee #188CLOSING — 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. Bothemit_callarms route through it now, which is the arm enumeration this issue asked for rather than a one-site patch: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-pluginsEXIT=0 (270 + 8 + 2 + 5 + 9 + 3 + 3 passed, 0 failed);precision_gateEXIT=0, 7/7 withphantoms=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 ataa5236ewith 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-5264already cuts each qualifier segment at<when building anchors, andcrates/indexer/tests/resolver.rs:2122 turbofish_qualifier_anchors_like_the_plain_spellinggrades it with a decoy. R4 is stored-string fidelity only; it does not cost a bind.Closing.
phantom_count == 0bounds ~50 hand-written probes, not the corpus, and it is cited across the tree as an absolute guarantee #188