@@ -36,12 +36,13 @@ func extractFileAST(src []byte, relFile string, isRails, exportedByPackwerk bool
3636
3737// rubyScope tracks a class/module/eigenclass nesting level.
3838type 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
4748type 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.
441446func (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 )
0 commit comments