Skip to content

Case-insensitive call resolution generates phantom calls edges in Ruby (Pundit authorizeAuthorize marker class) #993

Description

@rich-afd

Summary

symbol_resolution.normalise_callable_label (line 31) lowercases all labels
before building the call-resolution index. In Ruby — where casing has
semantic meaning (Foo is a class, foo is a method) — this collapses
distinct symbols into one bucket and emits high-confidence INFERRED calls
edges that don't reflect the source code.

I hit this on a Rails 8 / Packwerk codebase where Pundit's lowercase
authorize record helper is called from every controller action, and the
codebase has a separate marker class Api::Guards::Authorize. The
case-insensitive resolver concluded that every .create() / .update() /
etc. action calls the marker class — 188 spurious INFERRED edges at
score 0.8, all to the same target.

Minimal repro

# app/lib/api/guards/authorize.rb
module Api::Guards
  class Authorize < Base
    def call(_context) = Result.allow
  end
end

# app/controllers/foo_controller.rb  (inherits from BaseController which
# declares `guard Api::Guards::Authorize` at the class level)
class FooController < BaseController
  def create
    foo = Foo.create!(params)
    authorize foo            # <-- Pundit::Authorization#authorize (lowercase)
    render json: foo
  end
end

After graphify extract:

  • FooController#create --calls--> Api::Guards::Authorize (INFERRED, score 0.8)

But FooController#create doesn't call the Authorize class. It calls
Pundit's authorize instance method, which is a different symbol entirely.
The marker class is referenced exactly once in the codebase (in
BaseController via guard Api::Guards::Authorize), as a class-level DSL.

Impact

On the real codebase (a ~3,000-node Rails graph):

  • Authorize becomes the v3: semantic query with embeddings #1 god node with 190 edges (188 INFERRED)
    it should have ~3.
  • A whole community (35 nodes) is clustered around the phantom hub.
  • Community labels and "suggested questions" both surface this as the most
    central concept, when it's actually a passive marker.
  • The "Are these N INFERRED edges correct?" verification question that
    graphify suggests is well-targeted — but the edges' confidence_score
    of 0.8 is misleadingly high given how systematic the false positive is.

The single-unique-candidate guard at line 330 (if len(candidates) != 1)
normally prevents this kind of thing, but it doesn't help when the
case-collision happens to produce exactly one candidate.

Proposed fix

A few options, roughly in order of effort:

  1. Per-language casing policy. Add a case_sensitive_resolution flag
    to LanguageConfig and set it True for Ruby, Go, C#, Swift, Kotlin
    — languages where capitalized identifiers are conventionally distinct
    types. normalise_callable_label would skip .lower() when the call
    originates from a case-sensitive language. (Python keeps current
    behavior to handle Class() constructor-as-call patterns.)

  2. Skip resolution when target label case-differs from callee. Inside
    resolve_cross_file_raw_calls, after looking up candidates, drop any
    whose original (un-lowercased) label doesn't share the callee's
    first-char casing. Less invasive than (1), language-agnostic.

  3. User-configurable denylist. Honor a .graphify.yml at the project
    root with a resolution_exclude: list of labels that should never
    participate as call targets. Useful for marker classes, sentinels, and
    DSL helpers regardless of language.

(1) is the cleanest fix; (2) is the smallest patch; (3) is the most
flexible escape hatch. (1) and (3) compose well.

Repro environment: graphifyy 0.8.16, Python 3.11, Ruby AST via tree-sitter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions