fix(ruby): a call on a namespaced class links its caller to the class and its method (#2827)

A Ruby call on a constant written with its namespace, like
Billing::ChargeCard.call(...) or ::Foo.call, recorded no edge. Both
extractors (the kernel's ruby.rs and the wasm one) emitted the receiver's
references ref only for a bare `constant` receiver, so a scope_resolution
receiver got none. The call ref itself, Billing::ChargeCard.call, matched
no resolver strategy either: the method-call patterns reject a `::` in the
receiver. A class used only through such calls showed no callers and no
dependents.

Fix:
- Both engines treat a constant path (a constant, or a scope_resolution of
  constants, a leading `::` included) as a constant receiver and emit the
  references ref under the receiver as written. A path through a value
  (obj::Thing, self.class::LIMIT) still emits none, and .new stays an
  instantiation.
- A new resolver step, src/resolution/ruby-constant-paths.ts, resolves a
  Ruby references ref written as such a path, and a calls ref on one, the
  way Ruby looks the constant up: the class or module around the
  reference, then each namespace outside it, then the top level; a
  `::`-rooted path at the top level only. A call reaches the class's or
  module's own method, else the first one its ancestry declares
  (rubyAncestry, now exported). The step runs before the pre-filter,
  which dropped `::`-rooted names, and it is exclusive: a path the project
  doesn't declare (ActiveRecord::Base) or a class without the method links
  nothing, never a namesake by its last name, and production code never
  reaches a class only a test declares.

Validation:
- New __tests__/ruby-namespaced-receiver.test.ts (8 cases, each on the
  kernel and on wasm, plus a sync case) and a parity case in
  kernel-ruby-parity.test.ts. On main 16 of the 22 tests in the two files
  fail (the rest are guards and the old parity cases); all pass with the
  fix. Reverting only the resolver step, or only the wasm extractor,
  turns 13 and 10 of them red.
- A/B against main (kernel path), every edge triaged: sinatra +23 edges,
  rubocop +168, mastodon's Ruby tree (4,363 files) +676; 0 removed, nodes
  identical. 866 of 867 are right: references by the path as written or
  under an enclosing namespace (each such case checked against the call
  site's own nesting), calls to a class method, a module_function method,
  or an inherited class method. 1 is wrong: Paperclip::Transcoder.make
  reaches the instance method `make`, since a singleton method and an
  instance method share one qualified name today; a bare Transcoder.make
  does the same on main. Controls express and flask are byte-identical.
- kernel-parity.mjs --lang ruby: sinatra 147/147, rubocop 1799/1801 (the
  same 2 deferred on main), mastodon 3333/3333, 0 diffs. The fix arm's
  kernel and wasm graphs are byte-identical on sinatra and rubocop.
- Index time, median of 3 interleaved runs: +0.4% to +1.7%, the same as
  the controls (+0.8%, +1.3%).

Fixes #2503

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 05:20:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b47bc4963
commit dcb985f1d2
9 changed files with 529 additions and 13 deletions
+16 -1
View File
@@ -305,6 +305,18 @@ function swiftPropertyInfo(
return { nameNode: firstSimpleIdentifier(pattern), isLet, isComputed };
}
/**
* A Ruby constant, or a `scope_resolution` path of constants (`A::B::C`, a
* top-level `::A` / `::A::B`): what Ruby reads as a constant. A path through
* a value (`obj::Thing`, `self.class::LIMIT`) is not one.
*/
function isRubyConstantPath(node: SyntaxNode): boolean {
if (node.type === 'constant') return true;
if (node.type !== 'scope_resolution') return false;
const scope = getChildByField(node, 'scope');
return getChildByField(node, 'name')?.type === 'constant' && (!scope || isRubyConstantPath(scope));
}
/** True when `node` is (transitively) inside a C function body — i.e. a local,
* not a file/namespace-scope declaration. Walks the parent chain to the root. */
function hasFunctionAncestor(node: SyntaxNode): boolean {
@@ -5444,7 +5456,10 @@ export class TreeSitterExtractor {
// — is itself a dependency on that constant; emit a `references` ref so a
// class used only via its class methods still records a dependent (the edge
// the old receiver-only callee happened to provide, now made explicit).
if (!skip && receiverNode.type === 'constant') {
// So is one written with its namespace, `Billing::ChargeCard.call` or
// `::Foo.call` (#2503), under the receiver as written; a path through a
// value (`obj::Thing`, `self.class::LIMIT`) names no constant.
if (!skip && isRubyConstantPath(receiverNode)) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: receiverName,
+10
View File
@@ -41,6 +41,7 @@ import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-mo
import { matchShopifyThemeFile } from './shopify-themes';
import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls';
import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions';
import { matchRubyConstantPathRef } from './ruby-constant-paths';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver';
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
@@ -1574,6 +1575,15 @@ export class ReferenceResolver {
const phpQualified = resolvePhpQualifiedClassRef(ref, this.context);
if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref);
// A Ruby constant written with its namespace (`Billing::ChargeCard`,
// `::Foo`) and a call on one (`Billing::ChargeCard.call`) are what Ruby's
// constant lookup finds from where they are written, or nothing the
// project declares (see ./ruby-constant-paths). The pre-filter below
// drops a `::`-rooted name, and the strategies past it would take a
// namesake by its last name.
const rubyPath = matchRubyConstantPathRef(ref, this.context);
if (rubyPath !== undefined) return this.gateLanguage(rubyPath, ref);
// Fast pre-filter: skip if no symbol with this name exists anywhere
// AND the name doesn't match a local import. The import escape is
// necessary because re-export rename chains (`import { login }
+1 -1
View File
@@ -7805,7 +7805,7 @@ function isRubyMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti
}
/** A Ruby class's qualified name, those of its superclasses, and of every module mixed into any of them. */
function rubyAncestry(qn: string, context: ResolutionContext): Set<string> {
export function rubyAncestry(qn: string, context: ResolutionContext): Set<string> {
let memo = RUBY_ANCESTRY.get(context);
if (!memo) RUBY_ANCESTRY.set(context, (memo = new Map()));
const hit = memo.get(qn);
+118
View File
@@ -0,0 +1,118 @@
/**
* A Ruby constant written with its namespace — `Billing::ChargeCard`, or from
* the top level, `::Foo` and `::Billing::ChargeCard` — is the class, module
* or constant Ruby's lookup finds from where it is written (#2503): the
* nearest namespace around the reference that declares it, then the top
* level; one written from the top level is looked up there only. The
* extractor records such a call receiver as a `references` ref beside the
* call, as it does a bare one (`ChargeCard.call`), and the call itself as
* `Billing::ChargeCard.call`: a method of that class or module, its own or
* one its ancestry declares.
*
* Both resolve here alone. A path the project doesn't declare is a library's
* (`ActiveRecord::Base.transaction`), where the strategies past this one
* would take a project class or method sharing its last name; and a class
* the path names without the method (`Billing::ChargeCard.where`) has it from
* somewhere the index can't see, never from another class that declares one.
* Production code never names a class a test declares. A bare constant
* (`ChargeCard`) is left to the name strategies, as before.
*/
import type { Node } from '../types';
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types';
import { isTestPath } from '../search/query-utils';
import { rubyAncestry } from './name-matcher';
/** `A::B`, `::A`, `::A::B`: a constant path. Group 1 is a leading `::`. */
const CONSTANT_PATH = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)$/;
/** `A::B.m`, `::A.m`: a call on one. Group 3 is the method. */
const CONSTANT_PATH_CALL = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\.([A-Za-z_]\w*[?!=]?)$/;
/** What a Ruby constant is in the index: a class, a module, or a value (`RATES = {…}` is a `variable`). */
const NAMESPACE_KINDS: ReadonlySet<string> = new Set(['class', 'module']);
const VALUE_KINDS: ReadonlySet<string> = new Set(['variable', 'constant']);
const CONSTANT_KINDS: ReadonlySet<string> = new Set([...NAMESPACE_KINDS, ...VALUE_KINDS]);
/**
* What a Ruby `references` ref written as a constant path names, or the
* method a call on one reaches (see the file comment). Undefined for any
* other ref, which the other strategies resolve; null when the project
* declares no such constant, or the class or module it names has no method
* of the name.
*/
export function matchRubyConstantPathRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
if (ref.language !== 'ruby') return undefined;
if (ref.referenceKind === 'references') {
const m = CONSTANT_PATH.exec(ref.referenceName);
if (!m || !isNamespaced(m)) return undefined;
const target = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0];
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;
}
if (ref.referenceKind === 'calls') {
const m = CONSTANT_PATH_CALL.exec(ref.referenceName);
if (!m || !isNamespaced(m)) return undefined;
const owner = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0];
const method = owner && NAMESPACE_KINDS.has(owner.kind) ? methodOf(owner.qualifiedName, m[3]!, ref, context) : null;
return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.85 : 0.8, resolvedBy: 'qualified-name' } : null;
}
return undefined;
}
/** Whether a matched path is written with a `::`, which a bare `Foo` is not. */
function isNamespaced(m: RegExpExecArray): boolean {
return m[1] !== undefined || m[2]!.includes('::');
}
/**
* The declarations of the constant `path` names where `ref` is written: those
* of the first of `<scope>::<path>`, `<outer scope>::<path>`, … `<path>` the
* project declares, the scope being the class or module around the
* reference — only `<path>` itself when it is written from the top level.
* A class or module comes before a value of the name, then the reference's
* own file, then production code.
*/
function constantsNamed(path: string, absolute: boolean, ref: UnresolvedRef, context: ResolutionContext): Node[] {
const production = !isTestPath(ref.filePath);
let prefix = absolute ? '' : lexicalScope(ref, context);
for (;;) {
const qn = prefix ? `${prefix}::${path}` : path;
const found = context.getNodesByQualifiedName(qn).filter((n) =>
n.language === 'ruby' && CONSTANT_KINDS.has(n.kind) && !(production && isTestPath(n.filePath)));
if (found.length > 0) return ordered(found, ref);
if (!prefix) return [];
const cut = prefix.lastIndexOf('::');
prefix = cut >= 0 ? prefix.slice(0, cut) : '';
}
}
/** The qualified name of the innermost class or module around `ref`, or '' at the top level of its file. */
function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string {
let inner: Node | undefined;
for (const n of context.getNodesInFile(ref.filePath)) {
if (!NAMESPACE_KINDS.has(n.kind) || n.language !== 'ruby' || n.startLine > ref.line || n.endLine < ref.line) continue;
// Of the ranges around a point, the innermost starts last.
if (!inner || n.startLine > inner.startLine || (n.startLine === inner.startLine && n.startColumn > inner.startColumn)) inner = n;
}
return inner?.qualifiedName ?? '';
}
/**
* The method `name` of the class or module `owner`: its own, else the first
* of its superclasses and mixed-in modules that declares one (rubyAncestry).
* Production code never reaches one a test declares.
*/
function methodOf(owner: string, name: string, ref: UnresolvedRef, context: ResolutionContext): { node: Node; own: boolean } | null {
const production = !isTestPath(ref.filePath);
for (const qn of rubyAncestry(owner, context)) {
const methods = context.getNodesByQualifiedName(`${qn}::${name}`).filter((n) =>
n.kind === 'method' && n.language === 'ruby' && !(production && isTestPath(n.filePath)));
if (methods.length > 0) return { node: ordered(methods, ref)[0]!, own: qn === owner };
}
return null;
}
/** Anything before a value, then the reference's own file, then production code; the index's order within each. */
function ordered(nodes: Node[], ref: UnresolvedRef): Node[] {
const rank = (n: Node): number =>
(VALUE_KINDS.has(n.kind) ? 4 : 0) + (n.filePath === ref.filePath ? 0 : 2) + (isTestPath(n.filePath) ? 1 : 0);
return nodes.length < 2 ? nodes : [...nodes].sort((a, b) => rank(a) - rank(b));
}