mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 20:12:11 +08:00
A Ruby class method (`def self.build`, `def Widget.build`, or a def inside
`class << self`) was indexed exactly like an instance method: is_static 0,
under the same qualified name as an instance method of that name
(Widget::build twice). So every call took whichever of the two the class
declared first. Widget.build could reach `def build`, and widget.build on an
instance could reach `def self.build`. In the repro for issue 2503,
`card = Billing::ChargeCard.new(...); card.call` linked to `def self.call`
rather than `def call`.
Fix:
- Extraction, both engines: the Ruby isStatic hook (isRubyClassMethod in
languages/ruby.ts, is_class_method in codegraph-kernel/src/ruby.rs) marks a
singleton_method, or a method whose parent past one body_statement is a
singleton_class, as static. Every other def is marked not static, the same
way on both, so the graph only changes for class methods.
- Resolution, Ruby only, where the call says which kind it is:
rubyMethodsOfKindFirst orders a call's candidates with the wanted kind
first. A call on a constant (Strategy 1, its inherited walk, and the
namespaced path in ruby-constant-paths.ts) wants the class method. A
receiver typed by `x = Widget.new` (resolveMethodOnType) wants the
instance method. A bare call splits only a real pair, one qualified name
among its candidates as both kinds, keeping the caller's kind: a block
may run on another object, so nothing else moves. A receiver known only
by its name (Strategies 2 and 3) says nothing of its kind and is left as
it was: rubocop's `cop.cop_name` runs over cop classes. With only one
kind present, every path picks what it did before.
Validation:
- New __tests__/ruby-singleton-methods.test.ts (6 cases, each on the
kernel and on wasm) and a parity case in kernel-ruby-parity.test.ts. On
main 9 of the 18 tests in the two files fail, every one that checks the
new behaviour; the guards and the old parity cases pass. All pass with
the fix.
- A/B against main f7820b58 (kernel path): nodes are unchanged except
is_static going 0 to 1 on the class methods, 167 on sinatra, 358 on
rubocop and 266 on mastodon's Ruby tree. Edges: none added or removed.
20 changed their target or confidence: sinatra 10, rubocop 0, mastodon
10. 19 are right: 13 moved from the wrong twin to the right one, such as
InlineRenderer.render to def self.render and sinatra's instance methods'
force_encoding to def force_encoding, and 6 kept their target with
higher confidence. One kept a target wrong in both arms: a call inside
sinatra's define_method block. Controls express and flask are
byte-identical.
- The A/B narrowed two rules. Preferring instance methods for a receiver
known only by its name moved rubocop's 19 right cop.cop_name edges, and
dropping other-kind methods from any bare call moved block-DSL calls on
sinatra. Both are gone: only a real pair is split.
- kernel-parity.mjs --lang ruby is the same on both arms: sinatra 147/147,
rubocop 1799/1801 (2 deferred), 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: sinatra -3.7%, rubocop +0.5%,
mastodon +0.7%, controls express -2.7% and flask +1.0%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.4 KiB
TypeScript
121 lines
6.4 KiB
TypeScript
/**
|
|
* 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, rubyMethodsOfKindFirst } 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).
|
|
* Of a class's `def self.m` and `def m`, which share one qualified name, the
|
|
* call on the class reaches the class method. 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: rubyMethodsOfKindFirst(ordered(methods, ref), 'class')[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));
|
|
}
|