Repository: joernio/joern (scala)

Issue:
[ruby] Singleton Methods on Objects Follow-Up
Following from https://github.com/joernio/joern/pull/4734, the more natural modelling would be to keep these methods as method declarations instead of lambdas. This, however, comes at the catch of making the method full names unique, e.g.

```ruby
class Animal; end
animal = Animal.new

class << animal
  def bark
    "woof"
  end
end

def bark
  "meow"
end
```

For this purpose, the recommended method full name for the singleton method should look something like `:program.class<<animal.bark`. This conveys the kind of structure the method was declared in, and we no longer need to treat it as a lambda.

Pull request:
This PR handles
 * Modelling for singleton methods on objects changed to method declarations instead of lambdas

Resolves #4741

Patch:
diff --git a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstCreatorHelper.scala b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstCreatorHelper.scala
index d519c4e1deb8..2cd1c8275902 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstCreatorHelper.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstCreatorHelper.scala
@@ -19,6 +19,8 @@ trait AstCreatorHelper(implicit withSchemaValidation: ValidationMode) { this: As
 
   protected def computeClassFullName(name: String): String  = s"${scope.surroundingScopeFullName.head}.$name"
   protected def computeMethodFullName(name: String): String = s"${scope.surroundingScopeFullName.head}:$name"
+  protected def computeSingletonObjectMethodFullName(name: String): String =
+    s"${scope.surroundingScopeFullName.head}.$name"
 
   override def column(node: RubyNode): Option[Int]    = node.column
   override def columnEnd(node: RubyNode): Option[Int] = node.columnEnd
diff --git a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForExpressionsCreator.scala b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForExpressionsCreator.scala
index ee044c2bf10a..62220d4f5c92 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForExpressionsCreator.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForExpressionsCreator.scala
@@ -22,42 +22,43 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
   val tmpGen: FreshNameGenerator[String] = FreshNameGenerator(i => s"<tmp-$i>")
 
   protected def astForExpression(node: RubyNode): Ast = node match
-    case node: StaticLiteral            => astForStaticLiteral(node)
-    case node: HereDocNode              => astForHereDoc(node)
-    case node: DynamicLiteral           => astForDynamicLiteral(node)
-    case node: UnaryExpression          => astForUnary(node)
-    case node: BinaryExpression         => astForBinary(node)
-    case node: MemberAccess             => astForMemberAccess(node)
-    case node: MemberCall               => astForMemberCall(node)
-    case node: ObjectInstantiation      => astForObjectInstantiation(node)
-    case node: IndexAccess              => astForIndexAccess(node)
-    case node: SingleAssignment         => astForSingleAssignment(node)
-    case node: AttributeAssignment      => astForAttributeAssignment(node)
-    case node: TypeIdentifier           => astForTypeIdentifier(node)
-    case node: RubyIdentifier           => astForSimpleIdentifier(node)
-    case node: SimpleCall               => astForSimpleCall(node)
-    case node: RequireCall              => astForRequireCall(node)
-    case node: IncludeCall              => astForIncludeCall(node)
-    case node: YieldExpr                => astForYield(node)
-    case node: RangeExpression          => astForRange(node)
-    case node: ArrayLiteral             => astForArrayLiteral(node)
-    case node: HashLiteral              => astForHashLiteral(node)
-    case node: Association              => astForAssociation(node)
-    case node: IfExpression             => astForIfExpression(node)
-    case node: UnlessExpression         => astForUnlessExpression(node)
-    case node: RescueExpression         => astForRescueExpression(node)
-    case node: CaseExpression           => blockAst(NewBlock(), astsForCaseExpression(node).toList)
-    case node: MandatoryParameter       => astForMandatoryParameter(node)
-    case node: SplattingRubyNode        => astForSplattingRubyNode(node)
-    case node: AnonymousTypeDeclaration => astForAnonymousTypeDeclaration(node)
-    case node: ProcOrLambdaExpr         => astForProcOrLambdaExpr(node)
-    case node: RubyCallWithBlock[_]     => astForCallWithBlock(node)
-    case node: SelfIdentifier           => astForSelfIdentifier(node)
-    case node: BreakStatement           => astForBreakStatement(node)
-    case node: StatementList            => astForStatementList(node)
-    case node: ReturnExpression         => astForReturnStatement(node)
-    case node: DummyNode                => Ast(node.node)
-    case node: Unknown                  => astForUnknown(node)
+    case node: StaticLiteral                    => astForStaticLiteral(node)
+    case node: HereDocNode                      => astForHereDoc(node)
+    case node: DynamicLiteral                   => astForDynamicLiteral(node)
+    case node: UnaryExpression                  => astForUnary(node)
+    case node: BinaryExpression                 => astForBinary(node)
+    case node: MemberAccess                     => astForMemberAccess(node)
+    case node: MemberCall                       => astForMemberCall(node)
+    case node: ObjectInstantiation              => astForObjectInstantiation(node)
+    case node: IndexAccess                      => astForIndexAccess(node)
+    case node: SingleAssignment                 => astForSingleAssignment(node)
+    case node: AttributeAssignment              => astForAttributeAssignment(node)
+    case node: TypeIdentifier                   => astForTypeIdentifier(node)
+    case node: RubyIdentifier                   => astForSimpleIdentifier(node)
+    case node: SimpleCall                       => astForSimpleCall(node)
+    case node: RequireCall                      => astForRequireCall(node)
+    case node: IncludeCall                      => astForIncludeCall(node)
+    case node: YieldExpr                        => astForYield(node)
+    case node: RangeExpression                  => astForRange(node)
+    case node: ArrayLiteral                     => astForArrayLiteral(node)
+    case node: HashLiteral                      => astForHashLiteral(node)
+    case node: Association                      => astForAssociation(node)
+    case node: IfExpression                     => astForIfExpression(node)
+    case node: UnlessExpression                 => astForUnlessExpression(node)
+    case node: RescueExpression                 => astForRescueExpression(node)
+    case node: CaseExpression                   => blockAst(NewBlock(), astsForCaseExpression(node).toList)
+    case node: MandatoryParameter               => astForMandatoryParameter(node)
+    case node: SplattingRubyNode                => astForSplattingRubyNode(node)
+    case node: AnonymousTypeDeclaration         => astForAnonymousTypeDeclaration(node)
+    case node: ProcOrLambdaExpr                 => astForProcOrLambdaExpr(node)
+    case node: SingletonObjectMethodDeclaration => astForSingletonObjectMethodDeclaration(node)
+    case node: RubyCallWithBlock[_]             => astForCallWithBlock(node)
+    case node: SelfIdentifier                   => astForSelfIdentifier(node)
+    case node: BreakStatement                   => astForBreakStatement(node)
+    case node: StatementList                    => astForStatementList(node)
+    case node: ReturnExpression                 => astForReturnStatement(node)
+    case node: DummyNode                        => Ast(node.node)
+    case node: Unknown                          => astForUnknown(node)
     case x =>
       logger.warn(s"Unhandled expression of type ${x.getClass.getSimpleName}")
       astForUnknown(node)
@@ -785,6 +786,20 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
     typeRef
   }
 
+  private def astForSingletonObjectMethodDeclaration(node: SingletonObjectMethodDeclaration): Ast = {
+    val methodAstsWithRefs = astForMethodDeclaration(node, isSingletonObjectMethod = true)
+
+    // Set span contents
+    methodAstsWithRefs.flatMap(_.nodes).foreach {
+      case m: NewMethodRef => DummyNode(m.copy)(node.body.span.spanStart(m.code))
+      case _               =>
+    }
+
+    val Seq(typeRef, _) = methodAstsWithRefs
+
+    typeRef
+  }
+
   private def astForMethodCallArgument(node: RubyNode): Ast = {
     node match
       // Associations in method calls are keyword arguments
diff --git a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForFunctionsCreator.scala b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForFunctionsCreator.scala
index a1300b4cd216..5defedbce284 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForFunctionsCreator.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForFunctionsCreator.scala
@@ -36,12 +36,23 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
     * @return
     *   a method declaration with additional refs and types if specified.
     */
-  protected def astForMethodDeclaration(node: MethodDeclaration, isClosure: Boolean = false): Seq[Ast] = {
+  protected def astForMethodDeclaration(
+    node: RubyNode & ProcedureDeclaration,
+    isClosure: Boolean = false,
+    isSingletonObjectMethod: Boolean = false
+  ): Seq[Ast] = {
     val isInTypeDecl  = scope.surroundingAstLabel.contains(NodeTypes.TYPE_DECL)
     val isConstructor = (node.methodName == Defines.Initialize) && isInTypeDecl
     val methodName    = node.methodName
+
     // TODO: body could be a try
-    val fullName = computeMethodFullName(methodName)
+
+    val fullName = node match {
+      case x: SingletonObjectMethodDeclaration =>
+        computeSingletonObjectMethodFullName(s"class<<${x.baseClass.span.text}.$methodName")
+      case _ => computeMethodFullName(methodName)
+    }
+
     val method = methodNode(
       node = node,
       name = methodName,
@@ -80,7 +91,7 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
     }
 
     // Consider which variables are captured from the outer scope
-    val stmtBlockAst = if (isClosure) {
+    val stmtBlockAst = if (isClosure || isSingletonObjectMethod) {
       val baseStmtBlockAst = astForMethodBody(node.body, optionalStatementList)
       transformAsClosureBody(refs, baseStmtBlockAst)
     } else {
@@ -130,7 +141,8 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
     if (isConstructor) modifiers.addOne(ModifierTypes.CONSTRUCTOR)
 
     val prefixMemberAst =
-      if isClosure || isSurroundedByProgramScope then Ast() // program scope members are set elsewhere
+      if isClosure || isSingletonObjectMethod || isSurroundedByProgramScope then
+        Ast() // program scope members are set elsewhere
       else {
         // Singleton constructors that initialize @@ fields should have their members linked under the singleton class
         val methodMember = scope.surroundingTypeFullName match {
@@ -156,7 +168,7 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
       .foreach(Ast.storeInDiffGraph(_, diffGraph))
     // In the case of a closure, we expect this method to return a method ref, otherwise, we bind a pointer to a
     // method ref, e.g. self.foo = def foo(...)
-    if isClosure then refs else createMethodRefPointer(method) :: Nil
+    if isClosure || isSingletonObjectMethod then refs else createMethodRefPointer(method) :: Nil
   }
 
   private def transformAsClosureBody(refs: List[Ast], baseStmtBlockAst: Ast) = {
diff --git a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/RubyIntermediateAst.scala b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/RubyIntermediateAst.scala
index 9aa30aa42c81..42c13c7d3024 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/RubyIntermediateAst.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/RubyIntermediateAst.scala
@@ -133,6 +133,15 @@ object RubyIntermediateAst {
       with ProcedureDeclaration
       with AllowedTypeDeclarationChild
 
+  final case class SingletonObjectMethodDeclaration(
+    methodName: String,
+    parameters: List[RubyNode],
+    body: RubyNode,
+    baseClass: RubyNode
+  )(span: TextSpan)
+      extends RubyNode(span)
+      with ProcedureDeclaration
+
   sealed trait MethodParameter {
     def name: String
   }
@@ -440,7 +449,6 @@ object RubyIntermediateAst {
       case Some(givenParameters) => MethodDeclaration(name, givenParameters, body)(span)
       case None                  => MethodDeclaration(name, this.parameters, body)(span)
     }
-
   }
 
   /** A dummy class for wrapping around `NewNode` and allowing it to integrate with RubyNode classes.
diff --git a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/parser/RubyNodeCreator.scala b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/parser/RubyNodeCreator.scala
index e3760aa69b9d..365f97e4273c 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/parser/RubyNodeCreator.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/parser/RubyNodeCreator.scala
@@ -884,8 +884,9 @@ class RubyNodeCreator extends RubyParserBaseVisitor[RubyNode] {
                   MemberAccess(baseClass, ".", x.methodName)(
                     x.span.spanStart(s"${baseClass.span.text}.${x.methodName}")
                   )
-                val proc = ProcOrLambdaExpr(Block(x.parameters, x.body)(x.span))(x.span)
-                SingleAssignment(memberAccess, "=", proc)(
+                val singletonBlockMethod =
+                  SingletonObjectMethodDeclaration(x.methodName, x.parameters, x.body, baseClass)(x.span)
+                SingleAssignment(memberAccess, "=", singletonBlockMethod)(
                   ctx.toTextSpan.spanStart(s"${memberAccess.span.text} = ${x.span.text}")
                 )
               case x => x


Test patch:
diff --git a/joern-cli/frontends/rubysrc2cpg/src/test/scala/io/joern/rubysrc2cpg/querying/ClassTests.scala b/joern-cli/frontends/rubysrc2cpg/src/test/scala/io/joern/rubysrc2cpg/querying/ClassTests.scala
index 105d3e2aa615..860186c5d944 100644
--- a/joern-cli/frontends/rubysrc2cpg/src/test/scala/io/joern/rubysrc2cpg/querying/ClassTests.scala
+++ b/joern-cli/frontends/rubysrc2cpg/src/test/scala/io/joern/rubysrc2cpg/querying/ClassTests.scala
@@ -380,7 +380,7 @@ class ClassTests extends RubyCode2CpgFixture {
               identifier.code shouldBe "animal"
               fieldIdentifier.code shouldBe "bark"
 
-              rhs.typeFullName shouldBe "Test0.rb:<global>::program:<lambda>0&Proc"
+              rhs.typeFullName shouldBe "Test0.rb:<global>::program.class<<animal.bark"
             case xs => fail(s"Expected two arguments for assignment, got [${xs.code.mkString(",")}]")
           }
 
@@ -390,22 +390,19 @@ class ClassTests extends RubyCode2CpgFixture {
               identifier.code shouldBe "animal"
               fieldIdentifier.code shouldBe "legs"
 
-              rhs.typeFullName shouldBe "Test0.rb:<global>::program:<lambda>1&Proc"
+              rhs.typeFullName shouldBe "Test0.rb:<global>::program.class<<animal.legs"
             case xs => fail(s"Expected two arguments for assignment, got [${xs.code.mkString(",")}]")
           }
         case xs => fail(s"Expected five assignments, got [${xs.code.mkString(",")}]")
       }
     }
 
-    "Create lambda methods for methods on singleton object" in {
-      inside(cpg.method.isLambda.l) {
-        case barkLambda :: legsLambda :: Nil =>
-          val List(barkLambdaParam) = barkLambda.method.parameter.l
-          val List(legsLambdaParam) = legsLambda.method.parameter.l
-
-          barkLambdaParam.code shouldBe RubyDefines.Self
-          legsLambdaParam.code shouldBe RubyDefines.Self
-        case xs => fail(s"Expected two lambdas, got [${xs.code.mkString(",")}]")
+    "Create TYPE_DECL nodes for two singleton methods" in {
+      inside(cpg.typeDecl.name("(bark|legs)").l) {
+        case barkTypeDecl :: legsTypeDecl :: Nil =>
+          barkTypeDecl.fullName shouldBe "Test0.rb:<global>::program.class<<animal.bark"
+          legsTypeDecl.fullName shouldBe "Test0.rb:<global>::program.class<<animal.legs"
+        case xs => fail(s"Expected two type_decls, got [${xs.code.mkString(",")}]")
       }
     }
   }