A bare zero-arg Ruby call emits no reference row, so an idiomatically-called method ships a TRIPLE-earned zero — and SKILL.md now tells agents to trust it #294

Closed
opened 2026-09-22 20:32:55 +02:00 by buildagent · 0 comments
Member

Reported from a live Ruby project (a Zammad MCP server), and reproduced here before filing.

The measurement

module Probe
  class Thing
    def forbidden_response = [403, {}, ["no"]]   # called bare
    def resource_metadata_url = "https://x/y"    # called bare, in interpolation
    def issuer_host = "example.test"             # called bare, as an argument
    def allowed_app_ids = [1, 2, 3]              # called bare, then .include?
    def paren_called(x) = x                      # CONTROL: called with parens

    def call_them(status, host, app)
      body = case status when 403 then forbidden_response else "ok" end
      url  = "#{resource_metadata_url}/x"
      same = host.casecmp?(issuer_host)
      ok   = allowed_app_ids.include?(app)
      paren_called(1)
    end
  end
end

search_symbols on that file:

symbol how it is called ref_count name_fallback_count name_fallback_shape_excluded name_fallback_unmeasured
paren_called paren_called(1) 1 0 0 absent
forbidden_response bare 0 0 0 absent
resource_metadata_url bare 0 0 0 absent
issuer_host bare 0 0 0 absent
allowed_app_ids bare 0 0 0 absent

The index resolved exactly 1 reference in the file — the control.

Why this is the catastrophic reading, in our own words

SymbolRow::unmeasured's doc already describes this defect class exactly:

On a Rust const it was neither tight nor measured — the language plugins emit no reference row for a const use at all, so the counter had nothing to count and reported the absence of rows as an absence of references.

That is this, in Ruby. And all three disclosure fields agree it did not happen:

  • name_fallback_count: 0 — documented as "no unresolved ref matches this symbol's name, language and call shape"
  • name_fallback_shape_excluded: 0 — so the zero is not an artefact of the call-shape filter either
  • name_fallback_unmeasured absent — which the contract says means the zero was EARNED

search_symbols' own description states the contract: "A 0 IS EARNED: it ships only where the index holds a USE-BEARING row bearing this name … Where none does, the field is ABSENT and name_fallback_unmeasured names why IN THE ROW." There is no use-bearing row here. The field should have been absent with a reason. It shipped a zero instead.

The shape is NARROWER than "Ruby without parentheses"

The reporter said parenthesis-free calls produce no reference. Measured, that is not quite it, and the real boundary matters for the fix:

error "inside the same module"   # no parens, HAS an argument -> ref_count 2. RESOLVES.
paren_called(1)                  # parens                     -> ref_count 1. RESOLVES.
forbidden_response               # bare, zero-arg, no receiver -> NO ROW AT ALL.

Ruby's command syntax (foo "x") is unambiguously a call and is handled. The invisible shape is the bare, receiver-less, zero-argument identifier, which is syntactically identical to a local variable read and needs scope analysis to disambiguate.

That shape is not an edge case in Ruby. It is how attribute readers, predicates and memoized helpers are normally called.

Why NO_USE_REFERENCE_CHANNEL does not fire

The earned-zero test is evaluated per (language, kind) — "does a Ruby method have a use channel?" It does: both foo(x) and foo "x" produce rows. So the kind-level test passes and the zero is declared earned, while the specific call shape actually used in the codebase is invisible.

The blindness is per call shape; the test is per kind. That is the gap.

Why it is urgent now

v0.31.0 shipped skill://code-index/SKILL.md, which tells agents in its claim table that ref_count: 0 with name_fallback_count: 0 licenses "nothing references this". We are now actively instructing agents to trust a value that is wrong for idiomatic Ruby. safe_delete inherits it directly, and change_impact understates reach.

The reporter measured 3.3% of unqualified method calls resolving on their repo, and review_diff produced six false "untested change" findings because of it.

What is NOT broken, said explicitly

  • Qualified calls are honest. Auth.resolve_user(t) gave ref_count: 0 with name_fallback_shape_excluded: 2 — the filter disclosure working exactly as designed, and search_symbols documents that a 0 beside a non-zero there means "cannot tell unused from unrecorded".
  • find_callers already flags symbol_shape_excluded_refs, and the reporter confirms it never claimed "no callers".

So the disclosure machinery is right everywhere it has a row to reason about. The failure is confined to the shape that produces no row at all — which is precisely why it is the dangerous half, exactly as #125 argues for destructuring.

What closing it needs

  1. Emit a row for a bare identifier that could be a method call, unresolved, so name_fallback_count can see it. Scope analysis decides call-vs-local-variable; where it cannot, an unresolved row is the honest output and the resolver's existing tiers already refuse to bind what they cannot justify.
  2. Failing that, make the zero unearned. If the extractor structurally cannot see the shape, name_fallback_count must go ABSENT with a reason code — a new one, since neither NO_USE_REFERENCE_CHANNEL (kind-level) nor USES_MAY_BE_GENERATED describes it.
  3. Check precision_gate (7/7, phantom_count == 0). New rows are new resolution opportunities and must not manufacture phantoms.
  4. A fixture per language, in the shape of the seven-language projection gates — the standing rule is one clause on a structural fact, not a Ruby patch. Python/JS require parentheses, so Ruby is where this bites, but the claim being fixed is "a use the extractor cannot see must not present as an earned zero", which is cross-language.
  5. Until it is fixed, SKILL.md and search_symbols' description must say so. An agent following our own documented rule reaches a wrong conclusion on any Ruby repository today.
  • #125 — same family (a reference never extracted, so no counter can qualify it), for destructuring. This is the same argument with a much larger population.
  • #118 — a falsely earned zero that at least measured its population.

Reproduction

The fixture above, code-index index, then code-index query search_symbols '{"query":"","limit":30}'. Measured on 0.31.1.

Reported from a live Ruby project (a Zammad MCP server), and **reproduced here** before filing. ## The measurement ```ruby module Probe class Thing def forbidden_response = [403, {}, ["no"]] # called bare def resource_metadata_url = "https://x/y" # called bare, in interpolation def issuer_host = "example.test" # called bare, as an argument def allowed_app_ids = [1, 2, 3] # called bare, then .include? def paren_called(x) = x # CONTROL: called with parens def call_them(status, host, app) body = case status when 403 then forbidden_response else "ok" end url = "#{resource_metadata_url}/x" same = host.casecmp?(issuer_host) ok = allowed_app_ids.include?(app) paren_called(1) end end end ``` `search_symbols` on that file: | symbol | how it is called | `ref_count` | `name_fallback_count` | `name_fallback_shape_excluded` | `name_fallback_unmeasured` | |---|---|---|---|---|---| | `paren_called` | `paren_called(1)` | 1 | 0 | 0 | absent | | `forbidden_response` | bare | **0** | **0** | **0** | **absent** | | `resource_metadata_url` | bare | **0** | **0** | **0** | **absent** | | `issuer_host` | bare | **0** | **0** | **0** | **absent** | | `allowed_app_ids` | bare | **0** | **0** | **0** | **absent** | The index resolved exactly **1** reference in the file — the control. ## Why this is the catastrophic reading, in our own words `SymbolRow::unmeasured`'s doc already describes this defect class exactly: > On a Rust `const` it was neither tight nor measured — the language plugins emit no reference row for a const use at all, so the counter had nothing to count and **reported the absence of rows as an absence of references**. That is this, in Ruby. And all three disclosure fields agree it did not happen: * `name_fallback_count: 0` — documented as "no unresolved ref matches this symbol's name, language and call shape" * `name_fallback_shape_excluded: 0` — so the zero is not an artefact of the call-shape filter either * `name_fallback_unmeasured` **absent** — which the contract says means the zero was EARNED `search_symbols`' own description states the contract: *"A `0` IS EARNED: it ships only where the index holds a USE-BEARING row bearing this name … Where none does, the field is ABSENT and `name_fallback_unmeasured` names why IN THE ROW."* There is no use-bearing row here. The field should have been absent with a reason. It shipped a zero instead. ## The shape is NARROWER than "Ruby without parentheses" The reporter said parenthesis-free calls produce no reference. Measured, that is not quite it, and the real boundary matters for the fix: ```ruby error "inside the same module" # no parens, HAS an argument -> ref_count 2. RESOLVES. paren_called(1) # parens -> ref_count 1. RESOLVES. forbidden_response # bare, zero-arg, no receiver -> NO ROW AT ALL. ``` Ruby's command syntax (`foo "x"`) is unambiguously a call and is handled. The invisible shape is the **bare, receiver-less, zero-argument identifier**, which is syntactically identical to a local variable read and needs scope analysis to disambiguate. That shape is not an edge case in Ruby. It is how attribute readers, predicates and memoized helpers are normally called. ## Why `NO_USE_REFERENCE_CHANNEL` does not fire The earned-zero test is evaluated per **(language, kind)** — "does a Ruby method have a use channel?" It does: both `foo(x)` and `foo "x"` produce rows. So the kind-level test passes and the zero is declared earned, while the *specific call shape* actually used in the codebase is invisible. **The blindness is per call shape; the test is per kind.** That is the gap. ## Why it is urgent now v0.31.0 shipped `skill://code-index/SKILL.md`, which tells agents in its claim table that `ref_count: 0` with `name_fallback_count: 0` licenses "nothing references this". We are now actively instructing agents to trust a value that is wrong for idiomatic Ruby. `safe_delete` inherits it directly, and `change_impact` understates reach. The reporter measured 3.3% of unqualified method calls resolving on their repo, and `review_diff` produced **six false "untested change" findings** because of it. ## What is NOT broken, said explicitly * **Qualified calls are honest.** `Auth.resolve_user(t)` gave `ref_count: 0` with `name_fallback_shape_excluded: 2` — the filter disclosure working exactly as designed, and `search_symbols` documents that a `0` beside a non-zero there means "cannot tell unused from unrecorded". * `find_callers` already flags `symbol_shape_excluded_refs`, and the reporter confirms it never claimed "no callers". So the disclosure machinery is right everywhere it has a row to reason about. The failure is confined to the shape that produces **no row at all** — which is precisely why it is the dangerous half, exactly as #125 argues for destructuring. ## What closing it needs 1. **Emit a row for a bare identifier that could be a method call**, unresolved, so `name_fallback_count` can see it. Scope analysis decides call-vs-local-variable; where it cannot, an unresolved row is the honest output and the resolver's existing tiers already refuse to bind what they cannot justify. 2. **Failing that, make the zero unearned.** If the extractor structurally cannot see the shape, `name_fallback_count` must go ABSENT with a reason code — a new one, since neither `NO_USE_REFERENCE_CHANNEL` (kind-level) nor `USES_MAY_BE_GENERATED` describes it. 3. **Check `precision_gate` (7/7, `phantom_count == 0`)**. New rows are new resolution opportunities and must not manufacture phantoms. 4. **A fixture per language**, in the shape of the seven-language projection gates — the standing rule is one clause on a structural fact, not a Ruby patch. Python/JS require parentheses, so Ruby is where this bites, but the *claim* being fixed is "a use the extractor cannot see must not present as an earned zero", which is cross-language. 5. **Until it is fixed, SKILL.md and `search_symbols`' description must say so.** An agent following our own documented rule reaches a wrong conclusion on any Ruby repository today. ## Related * #125 — same family (a reference never extracted, so no counter can qualify it), for destructuring. This is the same argument with a much larger population. * #118 — a falsely earned zero that at least measured its population. ## Reproduction The fixture above, `code-index index`, then `code-index query search_symbols '{"query":"","limit":30}'`. Measured on `0.31.1`.
buildagent referenced this issue from a commit 2026-09-23 20:23:14 +02:00
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#294
No description provided.