Files
codegraph/__tests__/ruby-call-scope.test.ts
Colby MchenryandClaude Opus 5.5 df761e4596 fix(ruby): a method reached by name alone must be in reach (#2147)
Ruby types nothing, so two guesses produced most of a Ruby repo's wrong
call edges:
- `receiver.m()` through an untyped receiver took the project's one method
  named `m`. It now also needs a word shared between the receiver and the
  method's owner (`web_push_request.legacy_encrypt` → WebPushRequest);
  `self` / `self.class` are exempt.
- a bare `m()` inside a class took any class's `m`. It now reaches only the
  class's ancestry — superclasses and include/extend/prepend modules, read
  from source and resolved against the lexical nesting. Calls in module
  bodies and top-level blocks (specs, DSLs) are not judged.

A/B (edges removed / added): rubocop -4406/+262, mastodon -2642/+540,
jekyll -343/+10, lobsters -243/+4, sinatra -207/+2, devise -112/+3.
Deliberate losses: instance_eval'd DSL blocks inside a class method
(sinatra's own tests' `mock_app { get '/' }`), and helpers on a framework
class the repo reopens past an external superclass (devise setup_mailer).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:58:12 +00:00

138 lines
3.4 KiB
TypeScript

/**
* Ruby has nothing that types a receiver, so a method reached by name alone
* must earn it:
*
* - `receiver.m()` through an untyped receiver takes the one project method
* named `m` only when the receiver is named after its owner
* (`web_push_request.legacy_encrypt` → WebPushRequest). rubocop's
* `node.loc` — a rubocop-ast node — went to the project's lone `loc` 1,201
* times; lobsters' `value.to_s` to a short-id class's `to_s`.
* - a bare `m()` inside a class is a call on self: the class's own methods,
* its superclasses', and those of modules mixed into any of them. rubocop's
* `format(…)` — Kernel's — went to the LSP runtime's `format` 411 times.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ruby-scope-'));
const files: Record<string, string> = {
'lib/app/ext/regexp.rb': `module App
module Ext
module Expression
def loc
@loc
end
end
end
end
`,
'lib/app/lsp/runtime.rb': `module App
module LSP
class Runtime
def format(text)
text
end
end
end
end
`,
'lib/app/web_push_request.rb': `class WebPushRequest
def legacy_encrypt(payload)
payload
end
end
`,
'lib/app/cop/base.rb': `module App
module Cop
module RangeHelp
def range_between(a, b)
[a, b]
end
end
class Base
def add_offense(node)
node
end
end
end
end
`,
'lib/app/cop/style/foo.rb': `module App
module Cop
module Style
class Foo < Base
include RangeHelp
MSG = 'Use %<x>s.'
def on_send(node, web_push_request)
selector = node.loc
web_push_request.legacy_encrypt(selector)
add_offense(node)
range_between(1, 2)
format(MSG, x: 1)
end
end
end
end
end
`,
'lib/app/greeting.rb': `module Greeting
def greet
"Hello, " + display_name()
end
end
`,
'lib/app/person.rb': `class Person
include Greeting
def display_name
'Ada'
end
end
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `Owner::method` of every call edge out of a file. */
function callsFrom(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!.qualifiedName)
.sort();
}
describe('Ruby calls reached by name alone', () => {
it('keep the inherited and mixed-in methods, and the receiver named after its class', () => {
expect(callsFrom('lib/app/cop/style/foo.rb')).toEqual([
'App::Cop::Base::add_offense',
'App::Cop::RangeHelp::range_between',
'WebPushRequest::legacy_encrypt',
]);
});
it('leave a call in a module body alone: a module runs on whatever includes it', () => {
expect(callsFrom('lib/app/greeting.rb')).toEqual(['Person::display_name']);
});
});