fix(ruby): calls to !/? methods and calls on what .new made link their method (#3049)

Two kinds of Ruby call linked nothing. A call to a method whose name ends
in `!` or `?` on a receiver (`writer.flush!`, `writer.ready?` after
`writer = CacheWriter.new`, `CacheWriter.create!(1)`) failed the method-call
pattern, whose method part took no `!` or `?`. A method called straight on
a new object (`CacheWriter.new(key).write_cache`, `Billing::Ledger.new.charge!`)
was recorded by both extractors with the receiver's raw text, arguments and
blocks included, which no strategy matched.

Both extractors now record a call on `Const.new(...)`, `A::B.new` or
`::Foo.new` as `new Const().m`, the constructed-receiver shape TS/JS, Java
and C# already use; the wasm extractor and the kernel's ruby.rs emit the
same refs. ruby-constant-paths resolves that shape before the name
pre-filter: the class by Ruby's constant lookup from the namespaces around
the call, the method as the class's own instance method or the first its
ancestry declares, and nothing when either is missing, so rack-protection's
`Request.new(env)` (rack's Rack::Request) links nothing. The Ruby dot pattern
in matchMethodCall takes a trailing `!` or `?`; the receiver-word scoring
among several project methods of the name stays off for such names on a
value receiver, where it was wrong on 55 of 74 new edges (`options.key?`
on a Hash -> InheritableOptions#key?). rubyAncestry no longer reads a
module a call returns (`include RouteSet.new.tap {...}.url_helpers`) as a
mixin of the constant the call starts at.

Validation, kernel engine, main 37052b18 vs this change:
- sinatra cb22afd +12, jekyll 97c3bdc +53, activesupport +398,
  actionpack +97 (2 removed: `ActionController::Base.with` had reached
  RoutesHelpers.with through a misread mixin), activerecord +1012
  (rails 793d744 subtrees). About 21 of 1,572 added edges are wrong, all
  from the class-name and single-method guesses that plain names get too.
- Controls requests (Python) and cobra (Go): dump-graph byte-identical.
- kernel-parity.mjs: fixtures 10/10, sinatra 147/147, jekyll + rails
  1044/1044 files byte-parity, none deferred.
- New tests: 10 fail with main's kernel and TS, all pass with the fix, in
  both engines. Dropping the scoring gate or the mixin fix fails its guard
  test.
- Index time, median of 3 interleaved: activesupport 5.35 vs 4.63 s,
  jekyll 2.66 vs 2.73 s (noise).

Reimplements PR #684 by @ricobl, who found both gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 06:47:47 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b700a3eec2
commit 3a4cd3bf65
7 changed files with 428 additions and 10 deletions
+16 -3
View File
@@ -8397,7 +8397,9 @@ export function rubyAncestry(qn: string, context: ResolutionContext): Set<string
const sup = /^\s*class\s+[\w:]+\s*<\s*(::)?([A-Z][\w:]*)/.exec(lines[decl.startLine - 1] ?? '');
if (sup) queue.push(rubyConstantQn(sup[2]!, sup[1] ? '' : outer, context));
for (const line of lines.slice(decl.startLine, decl.endLine)) {
const mix = /^\s*(?:include|extend|prepend)\s+([A-Z:][\w:]*(?:\s*,\s*[A-Z:][\w:]*)*)/.exec(line);
// A module a call returns (`include RouteSet.new.tap { … }.url_helpers`,
// actionpack's tests) is no mixin of the constant the call starts at.
const mix = /^\s*(?:include|extend|prepend)\s+([A-Z:][\w:]*(?:\s*,\s*[A-Z:][\w:]*)*)(?![\w:]|\s*[.(])/.exec(line);
if (!mix) continue;
for (const name of mix[1]!.split(/\s*,\s*/)) {
queue.push(name.startsWith('::') ? rubyConstantQn(name.slice(2), '', context) : rubyConstantQn(name, q, context));
@@ -21822,8 +21824,10 @@ export function matchMethodCall(
// A JS/TS method name may carry `$`, which `\w` doesn't: an RxJS-style
// `this.organizationService.organizations$()`, vscode's RPC
// `this._proxy.$acceptModelChanged()`.
// A Ruby method name may end in `!` or `?`: `writer.flush!`, `writer.ready?` (#684).
const dotMatch =
(JS_FAMILY.has(ref.language) ? ref.referenceName.match(/^([\w.]+)\.([\w$]+)$/) : null) ??
(ref.language === 'ruby' ? ref.referenceName.match(/^([\w.]+)\.(\w+[!?])$/) : null) ??
ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ??
(ref.language === 'cpp'
? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/)
@@ -22800,8 +22804,17 @@ export function matchMethodCall(
};
}
// Multiple methods: score by receiver name word overlap with class name
if (targetMethods.length > 1 || (targetMethods.length === 1 && (scopedOut || pythonChain) && !narrowed)) {
// Multiple methods: score by receiver name word overlap with class name.
// Not for a Ruby name ending in `!` or `?` called on a value (#684):
// `key?`, `empty?`, `respond_to?`, `is_a?` and `merge!` are what the core
// classes answer on nearly every receiver, and the receiver's words can't
// tell which project method of the name, if any, is meant: activesupport's
// `options.key?` on a Hash went to `InheritableOptions#key?`, actionpack's
// `body.respond_to?` to `RackBody#respond_to?`. A constant receiver
// (`MarshalWithFallback.dumped?`) still names its owner.
const rubyBangOrPredicate = ref.language === 'ruby' && /[!?]$/.test(methodName!) && /^[a-z_]/.test(receiverLink(objectOrClass!));
if (!rubyBangOrPredicate &&
(targetMethods.length > 1 || (targetMethods.length === 1 && (scopedOut || pythonChain) && !narrowed))) {
// What the receiver is named after is its last link: `builder.tokeniser`
// is a Tokeniser, `table.Columns` no kind of table. In a python chain the
// last link as written: Django's `request.META` and `request.GET` are
+33 -6
View File
@@ -16,16 +16,25 @@
* 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.
*
* A call on what `.new` made (`ChargeCard.new(card).charge!`, recorded as
* `new ChargeCard().charge!`) resolves here too, bare constant or path: an
* instance method of the class the lookup finds, or nothing (#684).
*/
import type { Node } from '../types';
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types';
import { isTestPath } from '../search/query-utils';
import { rubyAncestry, rubyMethodsOfKindFirst } from './name-matcher';
import { rubyAncestry, rubyMethodsOfKindFirst, type RubyMethodKind } 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*[?!=]?)$/;
/**
* `new Foo().m`, `new A::B().m!`: a call on what `Foo.new(…)` or `A::B.new`
* made, as the extractor records it (#684). Group 3 is the method.
*/
const CONSTRUCTED_CALL = /^new (::)?([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']);
@@ -48,6 +57,17 @@ export function matchRubyConstantPathRef(ref: UnresolvedRef, context: Resolution
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;
}
if (ref.referenceKind === 'calls') {
// A call on an instance `Foo.new(…)` made, bare or namespaced: an instance
// method of the class Ruby's lookup finds for `Foo` there, its own or one
// its ancestry declares — never a namesake's by the method name alone. A
// class the project doesn't declare (rack-protection's `Request.new(env)`
// is rack's) has no method here.
const made = CONSTRUCTED_CALL.exec(ref.referenceName);
if (made) {
const cls = constantsNamed(made[2]!, made[1] !== undefined, ref, context)[0];
const method = cls?.kind === 'class' ? methodOf(cls.qualifiedName, made[3]!, ref, context, 'instance') : null;
return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.9 : 0.85, resolvedBy: 'instance-method' } : null;
}
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];
@@ -98,16 +118,23 @@ function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string {
/**
* 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.
* Of a class's `def self.m` and `def m`, which share one qualified name, a
* call on the class reaches the class method, and a call on an instance
* (`kind` 'instance') the instance method. Production code never reaches one
* a test declares.
*/
function methodOf(owner: string, name: string, ref: UnresolvedRef, context: ResolutionContext): { node: Node; own: boolean } | null {
function methodOf(
owner: string,
name: string,
ref: UnresolvedRef,
context: ResolutionContext,
kind: RubyMethodKind = 'class',
): { 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 };
if (methods.length > 0) return { node: rubyMethodsOfKindFirst(ordered(methods, ref), kind)[0]!, own: qn === owner };
}
return null;
}