Skip to content

Commit d08a35d

Browse files
committed
Improving RoR graph
1 parent d196a4b commit d08a35d

4 files changed

Lines changed: 226 additions & 13 deletions

File tree

internal/engine/cache.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import (
1919
// v3: Python route facts use method/role/bare-path Name (was http_method, verb-in-name).
2020
// v4: Java HTTP client detection (RestTemplate call sites + @FeignClient interfaces).
2121
// v5: PHP HTTP client detection + Laravel/Symfony route DSLs (attributes, YAML/XML config).
22-
const cacheVersion = "v5"
22+
// v6: Ruby bare-constant references emitted as RelCalls edges (dead-code precision).
23+
// v7: Ruby skips builtin-constant edges (god-class noise) + serializer attribute/include_ folding.
24+
const cacheVersion = "v7"
2325

2426
// extractorCache holds per-extractor facts keyed by a content hash of the files
2527
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/extractors/rubyextractor/ruby_ast.go

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ func extractFileAST(src []byte, relFile string, isRails, exportedByPackwerk bool
3636

3737
// rubyScope tracks a class/module/eigenclass nesting level.
3838
type rubyScope struct {
39-
name string // simple (last) name; "" for an eigenclass (class << self)
40-
kind string // "class", "module", or "eigenclass"
41-
visibility string // "public" | "private" | "protected"
42-
moduleFunc bool // module_function active: subsequent defs are class methods
43-
isModel bool // ActiveRecord model: associations/scopes/table_name apply
44-
symFactIdx int // index into w.out of this scope's class/module symbol fact, or -1
39+
name string // simple (last) name; "" for an eigenclass (class << self)
40+
kind string // "class", "module", or "eigenclass"
41+
visibility string // "public" | "private" | "protected"
42+
moduleFunc bool // module_function active: subsequent defs are class methods
43+
isModel bool // ActiveRecord model: associations/scopes/table_name apply
44+
isSerializer bool // ActiveModel::Serializer: attributes/associations back methods
45+
symFactIdx int // index into w.out of this scope's class/module symbol fact, or -1
4546
}
4647

4748
type rubyWalker struct {
@@ -344,7 +345,8 @@ func (w *rubyWalker) handleClass(node *sitter.Node) {
344345
})
345346
}
346347

347-
w.push(rubyScope{name: name, kind: "class", visibility: "public", isModel: isModel, symFactIdx: clsIdx})
348+
w.push(rubyScope{name: name, kind: "class", visibility: "public", isModel: isModel,
349+
isSerializer: isSerializerBase(superclass), symFactIdx: clsIdx})
348350
w.walkBody(node.ChildByFieldName("body"))
349351
w.pop()
350352
}
@@ -430,14 +432,17 @@ func (w *rubyWalker) handleMethod(node *sitter.Node, isClassMethod bool) {
430432
// RelCalls edges to the owner fact. It does not descend into nested
431433
// method/class/module definitions — those receive their own owner.
432434
//
433-
// Three reference shapes are captured: (1) qualified calls via callTarget
435+
// Four reference shapes are captured: (1) qualified calls via callTarget
434436
// ("Const.method", "var.method"); (2) bare calls with a method name but no
435437
// receiver ("render :x", "helper(arg)") → the bare method name; (3) lone
436438
// identifiers in expression position ("current_user") that are not known locals
437-
// → the bare name. (2) and (3) are why Ruby methods invoked without a receiver
438-
// (the common Rails case) are now recorded as referenced. Bare targets carry no
439-
// "." and never resolve to a constant, so the package-metrics coupling graph
440-
// (which keys off constant receivers) is unaffected.
439+
// → the bare name; (4) bare constant references ("MyJob", "Chat::Message") used
440+
// as values → the constant name. (2) and (3) are why Ruby methods invoked without
441+
// a receiver (the common Rails case) are recorded as referenced; (4) is why a
442+
// class/module used only as a value (registered, passed as an argument, matched in
443+
// case/when) is. Bare targets — from (2), (3) and (4) — carry no ".", so
444+
// constFromCall ignores them and the package-metrics coupling graph (which keys
445+
// off "Recv.method" constant receivers) is unaffected.
441446
func (w *rubyWalker) walkForCalls(node *sitter.Node, ownerIdx int, seen, locals map[string]bool) {
442447
if node == nil {
443448
return
@@ -538,6 +543,22 @@ func (w *rubyWalker) walkForCalls(node *sitter.Node, ownerIdx int, seen, locals
538543
w.recordCallMetrics(name)
539544
}
540545
return
546+
case "constant", "scope_resolution":
547+
// A bare constant reference in expression position — an argument
548+
// (register(MyJob)), array/hash element, case/when or rescue clause,
549+
// assignment RHS, or a lone `Foo` value. It is NOT a `Const.method` call
550+
// (that is captured as the receiver via callTarget above) and NOT a
551+
// definition name (handleClass/handleModule consume those), so without this
552+
// a class/module used only as a value looks unreferenced and is mis-reported
553+
// as dead. Record it as a use of that constant. The target carries no ".",
554+
// so constFromCall ignores it and the package-metrics coupling graph is
555+
// unaffected; it is not a method invocation, so perf metrics are untouched.
556+
// scope_resolution is recorded whole (e.g. "Chat::Message") and not
557+
// descended into, so the qualified path is matched rather than its segments.
558+
if name := stripLeadingColons(rubyText(node, w.src)); name != "" && !rubyBuiltinConsts[name] {
559+
w.addCall(ownerIdx, seen, name)
560+
}
561+
return
541562
}
542563
for i := uint(0); i < node.ChildCount(); i++ {
543564
w.walkForCalls(node.Child(i), ownerIdx, seen, locals)
@@ -587,6 +608,43 @@ var rubyCallbackDSL = map[string]bool{
587608
"validate": true,
588609
}
589610

611+
// rubySerializerDSL are ActiveModel::Serializer class-body methods whose symbol
612+
// arguments name attributes/associations. Each declared name is backed by an
613+
// optional same-named method (and an `include_<name>?` predicate) that the
614+
// serializer framework invokes — never an explicit Ruby call — so they are folded
615+
// in as references (see handleBodyCall). Applied only inside a serializer class.
616+
var rubySerializerDSL = map[string]bool{
617+
"attributes": true, "attribute": true,
618+
"has_one": true, "has_many": true, "belongs_to": true, "has_and_belongs_to_many": true,
619+
}
620+
621+
// rubyBuiltinConsts are Ruby core and common stdlib constants. A bare reference to
622+
// one is real, but emitting a call edge to it inflates fan-in on monkey-patch
623+
// reopenings (Discourse's freedom_patches `class Array`/`String`/`Time`), turning
624+
// uninteresting core classes into spurious god-class / hotspot findings while
625+
// never being a useful dead-code lead. They are skipped when recording bare
626+
// constant references. Namespaced constants (Foo::Array) are unaffected.
627+
var rubyBuiltinConsts = map[string]bool{
628+
"Object": true, "BasicObject": true, "Module": true, "Class": true, "Method": true,
629+
"UnboundMethod": true, "Proc": true, "Binding": true, "Data": true,
630+
"Array": true, "Hash": true, "String": true, "Symbol": true, "Set": true,
631+
"Integer": true, "Float": true, "Numeric": true, "Rational": true, "Complex": true,
632+
"Range": true, "Regexp": true, "MatchData": true, "Struct": true, "Enumerator": true,
633+
"TrueClass": true, "FalseClass": true, "NilClass": true,
634+
"Time": true, "Date": true, "DateTime": true,
635+
"Comparable": true, "Enumerable": true, "Kernel": true, "Math": true,
636+
"IO": true, "File": true, "Dir": true, "FileUtils": true, "Pathname": true,
637+
"StringIO": true, "Tempfile": true,
638+
"Thread": true, "Mutex": true, "ConditionVariable": true, "Queue": true,
639+
"SizedQueue": true, "Fiber": true, "ThreadGroup": true,
640+
"Exception": true, "StandardError": true, "RuntimeError": true, "ArgumentError": true,
641+
"TypeError": true, "NameError": true, "NoMethodError": true, "IndexError": true,
642+
"KeyError": true, "RangeError": true, "IOError": true, "NotImplementedError": true,
643+
"StopIteration": true, "ZeroDivisionError": true, "FrozenError": true,
644+
"Marshal": true, "ObjectSpace": true, "GC": true, "Process": true, "Signal": true,
645+
"Encoding": true, "Random": true, "SecureRandom": true, "Mutex_m": true,
646+
}
647+
590648
// rubyNonCalls are bare identifiers that must not be treated as method-call
591649
// references: keywords/builtins that commonly appear in expression position.
592650
// (Most Ruby keywords — self, nil, super, yield, return — are their own AST node
@@ -768,6 +826,22 @@ func (w *rubyWalker) handleBodyCall(node *sitter.Node) {
768826
return
769827
}
770828

829+
// ActiveModel::Serializer attribute/association DSL: `attributes :a, :b`,
830+
// `attribute :c`, `has_one :user`, `has_many :posts`. Each declared name is
831+
// backed by a same-named method the serializer framework calls (when defined),
832+
// plus an optional `include_<name>?` predicate it calls to decide inclusion —
833+
// neither is an explicit Ruby call, so the backing methods look dead. Fold both
834+
// forms in as references on the enclosing serializer class. Gated on isSerializer
835+
// so the shared has_one/has_many/belongs_to names still reach the ActiveRecord
836+
// association handling below for models.
837+
if cur := w.cur(); cur != nil && cur.isSerializer && cur.symFactIdx >= 0 && rubySerializerDSL[method] {
838+
for _, name := range symbolArgs(args, w.src) {
839+
w.addCallToFact(cur.symFactIdx, name)
840+
w.addCallToFact(cur.symFactIdx, "include_"+name+"?")
841+
}
842+
return
843+
}
844+
771845
switch method {
772846
case "require", "require_relative":
773847
path := firstStringArg(args, w.src)

internal/extractors/rubyextractor/ruby_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,135 @@ end
252252
}
253253
}
254254

255+
// TestExtractFile_BareConstantReferences checks that a constant used as a value
256+
// (registered, passed as an argument, matched in case/when, in an array) is
257+
// recorded as a RelCalls edge so the referenced class/module is not mis-reported
258+
// as dead code. scope_resolution paths are recorded whole.
259+
func TestExtractFile_BareConstantReferences(t *testing.T) {
260+
src := `class Registry
261+
def wire
262+
register(MyJob)
263+
handlers = [FooHandler, BarHandler]
264+
klass = Chat::Message
265+
case obj
266+
when SomeError
267+
retry
268+
end
269+
end
270+
end
271+
`
272+
result := extractFileAST([]byte(src), "app/services/registry.rb", false, true)
273+
meth, ok := symbolsByName(result)["Registry#wire"]
274+
if !ok {
275+
t.Fatal("missing method Registry#wire")
276+
}
277+
for _, want := range []string{"MyJob", "FooHandler", "BarHandler", "Chat::Message", "SomeError"} {
278+
if !hasCall(meth, want) {
279+
t.Errorf("missing bare-constant RelCalls -> %s; relations = %v", want, meth.Relations)
280+
}
281+
}
282+
// The bare constant target carries no ".", so it is not a coupling-graph
283+
// "Recv.method" form — guard that we did not accidentally emit one.
284+
if hasCall(meth, "MyJob.register") {
285+
t.Errorf("bare constant must not become a Recv.method target; relations = %v", meth.Relations)
286+
}
287+
}
288+
289+
// TestExtractFile_ConstantReceiverCallStillQualified checks that the bare-constant
290+
// capture does not regress the qualified-call form: a Const.method call must still
291+
// produce the "Const.method" edge (for coupling), now alongside a bare "Const" one.
292+
func TestExtractFile_ConstantReceiverCallStillQualified(t *testing.T) {
293+
src := `class Builder
294+
def run(ids)
295+
Items::Facade.fetch(ids)
296+
end
297+
end
298+
`
299+
result := extractFileAST([]byte(src), "app/services/builder.rb", false, true)
300+
meth := symbolsByName(result)["Builder#run"]
301+
if !hasCall(meth, "Items::Facade.fetch") {
302+
t.Errorf("qualified call edge lost; relations = %v", meth.Relations)
303+
}
304+
if !hasCall(meth, "Items::Facade") {
305+
t.Errorf("missing bare-receiver constant edge; relations = %v", meth.Relations)
306+
}
307+
}
308+
309+
// TestExtractFile_BuiltinConstantsSkipped checks that bare references to Ruby
310+
// core/stdlib constants are not emitted as call edges (they inflate fan-in on
311+
// monkey-patch reopenings), while application constants still are.
312+
func TestExtractFile_BuiltinConstantsSkipped(t *testing.T) {
313+
src := `class Worker
314+
def run
315+
Array.new
316+
x = [String, Time]
317+
enqueue(MyJob)
318+
end
319+
end
320+
`
321+
result := extractFileAST([]byte(src), "app/services/worker.rb", false, true)
322+
meth := symbolsByName(result)["Worker#run"]
323+
for _, skip := range []string{"Array", "String", "Time"} {
324+
if hasCall(meth, skip) {
325+
t.Errorf("builtin constant %s must not be emitted as a call edge; relations = %v", skip, meth.Relations)
326+
}
327+
}
328+
if !hasCall(meth, "MyJob") {
329+
t.Errorf("application constant MyJob should still be recorded; relations = %v", meth.Relations)
330+
}
331+
}
332+
333+
// TestExtractFile_SerializerAttributeFold checks that a serializer's attribute and
334+
// association DSL folds the backing methods (and the include_<name>? predicate) in
335+
// as references on the serializer class, so they are not mis-reported as dead.
336+
func TestExtractFile_SerializerAttributeFold(t *testing.T) {
337+
src := `class PostSerializer < ApplicationSerializer
338+
attributes :cooked, :score
339+
has_one :user
340+
def cooked
341+
object.cooked
342+
end
343+
def include_score?
344+
scope.admin?
345+
end
346+
end
347+
`
348+
result := extractFileAST([]byte(src), "app/serializers/post_serializer.rb", true, true)
349+
cls, ok := symbolsByName(result)["PostSerializer"]
350+
if !ok {
351+
t.Fatal("missing class PostSerializer")
352+
}
353+
for _, want := range []string{"cooked", "include_cooked?", "score", "include_score?", "user", "include_user?"} {
354+
if !hasCall(cls, want) {
355+
t.Errorf("missing serializer DSL fold RelCalls -> %s; relations = %v", want, cls.Relations)
356+
}
357+
}
358+
}
359+
360+
// TestExtractFile_NonSerializerHasManyUnaffected guards that has_many on a model
361+
// (not a serializer) still produces an association dependency fact and is NOT
362+
// short-circuited by the serializer fold.
363+
func TestExtractFile_NonSerializerHasManyUnaffected(t *testing.T) {
364+
src := `class User < ApplicationRecord
365+
has_many :posts
366+
end
367+
`
368+
result := extractFileAST([]byte(src), "app/models/user.rb", true, true)
369+
var sawAssoc bool
370+
for _, f := range result {
371+
if f.Kind == facts.KindDependency {
372+
for _, r := range f.Relations {
373+
if r.Kind == facts.RelDependsOn && r.Target == "Post" {
374+
sawAssoc = true
375+
}
376+
}
377+
}
378+
}
379+
if !sawAssoc {
380+
t.Error("model has_many :posts should still emit a depends_on association fact (serializer fold must not swallow it)")
381+
}
382+
}
383+
255384
func TestExtractFile_BareCallSkipsLocalsAndKeywords(t *testing.T) {
256385
src := `class A
257386
def b

internal/extractors/rubyextractor/storage.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ func isARBaseClass(superclass string) bool {
3232
return false
3333
}
3434

35+
// isSerializerBase reports whether a superclass marks an ActiveModel::Serializer
36+
// subclass. By convention every serializer base ends in "Serializer"
37+
// (ApplicationSerializer, BasicPostSerializer, ActiveModel::Serializer, …), so the
38+
// suffix is a reliable, dependency-free signal.
39+
func isSerializerBase(superclass string) bool {
40+
return strings.HasSuffix(superclass, "Serializer")
41+
}
42+
3543
// inferTableName derives the conventional Rails table name from a class name.
3644
// e.g. "Item" -> "items", "UserAddress" -> "user_addresses", "Api::V2::Item" -> "items"
3745
func inferTableName(className string) string {

0 commit comments

Comments
 (0)