mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-12 05:00:25 +08:00
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>
138 lines
3.4 KiB
TypeScript
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']);
|
|
});
|
|
});
|