mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
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:
co-authored by
Claude Opus 5.5
parent
5b47bc4963
commit
dcb985f1d2
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user