diff --git a/go/internal/detector/jvm/kotlin/ktor_routes.go b/go/internal/detector/jvm/kotlin/ktor_routes.go index b96c9c73..8f1a4b06 100644 --- a/go/internal/detector/jvm/kotlin/ktor_routes.go +++ b/go/internal/detector/jvm/kotlin/ktor_routes.go @@ -12,6 +12,10 @@ import ( // KtorRouteDetector mirrors Java KtorRouteDetector regex tier. Detects // `routing { get("/p") { } }` blocks, `route("/api") {` prefixes, // `authenticate("...") {` guards, and `install(...)` features. +// +// REQUIRES a Ktor-specific discriminator import (`io.ktor`) to avoid false +// positives on plain Kotlin code that uses similar DSL idioms (e.g. custom +// routing DSLs, test fixtures, or coroutine builders named `routing`/`get`). type KtorRouteDetector struct{} func NewKtorRouteDetector() *KtorRouteDetector { return &KtorRouteDetector{} } @@ -71,6 +75,14 @@ func (d KtorRouteDetector) Detect(ctx *detector.Context) *detector.Result { if text == "" { return detector.EmptyResult() } + + // Discriminator: require an io.ktor import to avoid false positives on + // plain Kotlin code that uses DSL patterns (routing {}, get("...") {}) + // without any Ktor dependency. + if !strings.Contains(text, "io.ktor") { + return detector.EmptyResult() + } + fp := ctx.FilePath var nodes []*model.CodeNode diff --git a/go/internal/detector/jvm/kotlin/ktor_routes_test.go b/go/internal/detector/jvm/kotlin/ktor_routes_test.go index 41a18117..42259980 100644 --- a/go/internal/detector/jvm/kotlin/ktor_routes_test.go +++ b/go/internal/detector/jvm/kotlin/ktor_routes_test.go @@ -90,6 +90,50 @@ func TestKtorRoutesNegative(t *testing.T) { } } +// TestKtorRoutesNoFireOnPlainDSL verifies the import discriminator prevents false +// positives when plain Kotlin code uses DSL patterns that visually resemble Ktor +// (routing {}, get("/path") {}, install(...)) but has no io.ktor import. +func TestKtorRoutesNoFireOnPlainDSL(t *testing.T) { + d := NewKtorRouteDetector() + plainWithDSL := `package com.example + +fun runTest() { + routing { println("not ktor") } + get("/fake") { doSomething() } + post("/fake") { doSomething() } + install(Something) +} +` + ctx := &detector.Context{FilePath: "src/PlainUtils.kt", Language: "kotlin", Content: plainWithDSL} + r := d.Detect(ctx) + if len(r.Nodes) != 0 { + t.Fatalf("expected 0 nodes on plain Kotlin DSL without io.ktor import, got %d nodes", len(r.Nodes)) + } +} + +// TestKtorRoutesNoFireOnFixturePlainUtils verifies zero framework emissions on +// the PlainUtils.kt fixture (stdlib only, no framework imports). +func TestKtorRoutesNoFireOnFixturePlainUtils(t *testing.T) { + d := NewKtorRouteDetector() + plainUtils := `package com.example.utils + +fun add(a: Int, b: Int): Int = a + b + +fun greet(name: String): String = "Hello, $name!" + +class Counter(initial: Int) { + private var count = initial + fun increment() { count++ } + fun value(): Int = count +} +` + ctx := &detector.Context{FilePath: "src/PlainUtils.kt", Language: "kotlin", Content: plainUtils} + r := d.Detect(ctx) + if len(r.Nodes) != 0 { + t.Fatalf("expected 0 framework nodes on PlainUtils.kt, got %d", len(r.Nodes)) + } +} + func TestKtorRoutesDeterminism(t *testing.T) { d := NewKtorRouteDetector() ctx := &detector.Context{FilePath: "src/Routes.kt", Language: "kotlin", Content: ktorRoutesSample} diff --git a/go/internal/detector/jvm/scala/scala_structures_test.go b/go/internal/detector/jvm/scala/scala_structures_test.go index 5dd8c222..f0cd82be 100644 --- a/go/internal/detector/jvm/scala/scala_structures_test.go +++ b/go/internal/detector/jvm/scala/scala_structures_test.go @@ -121,6 +121,44 @@ func TestScalaStructuresNegative(t *testing.T) { } } +// TestScalaStructuresNoFrameworkEmissions verifies the structures detector emits +// ONLY structural nodes (class/interface/method) on plain Scala files — no +// framework-flavored (endpoint, middleware, guard) nodes regardless of content. +func TestScalaStructuresNoFrameworkEmissions(t *testing.T) { + d := NewScalaStructuresDetector() + plainUtils := `package com.example.utils + +object PlainUtils { + def add(a: Int, b: Int): Int = a + b + def greet(name: String): String = s"Hello, $name!" +} + +class Counter(initial: Int) { + private var count = initial + def increment(): Unit = { count += 1 } + def value: Int = count +} +` + ctx := &detector.Context{FilePath: "PlainUtils.scala", Language: "scala", Content: plainUtils} + r := d.Detect(ctx) + for _, n := range r.Nodes { + switch n.Kind { + case model.NodeClass, model.NodeInterface, model.NodeMethod: + // expected structural nodes — OK + default: + t.Errorf("unexpected framework node kind %q (id=%q) on plain Scala file", n.Kind, n.ID) + } + } + for _, e := range r.Edges { + switch e.Kind { + case model.EdgeImports, model.EdgeExtends, model.EdgeImplements: + // expected structural edges — OK + default: + t.Errorf("unexpected framework edge kind %q on plain Scala file", e.Kind) + } + } +} + func TestScalaStructuresDeterminism(t *testing.T) { d := NewScalaStructuresDetector() ctx := &detector.Context{FilePath: "src/A.scala", Language: "scala", Content: scalaStructuresSample} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/ExposedRepo.kt b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/ExposedRepo.kt new file mode 100644 index 00000000..914d76fe --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/ExposedRepo.kt @@ -0,0 +1,20 @@ +package com.example.exposed + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction + +object Users : Table("users") { + val id = integer("id").autoIncrement() + val name = varchar("name", 128) + override val primaryKey = PrimaryKey(id) +} + +fun insertUser(name: String) = transaction { + Users.insert { it[Users.name] = name } +} + +fun listUsers(): List = transaction { + Users.selectAll().map { it[Users.name] } +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/KtorService.kt b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/KtorService.kt new file mode 100644 index 00000000..0ae51a02 --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/KtorService.kt @@ -0,0 +1,24 @@ +package com.example.ktor + +import io.ktor.server.application.* +import io.ktor.server.routing.* +import io.ktor.server.response.* +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty + +fun Application.module() { + routing { + route("/api") { + get("/health") { + call.respondText("OK") + } + post("/echo") { + call.respondText("echo") + } + } + } +} + +fun main() { + embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true) +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainParser.kt b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainParser.kt new file mode 100644 index 00000000..2a3dd2e4 --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainParser.kt @@ -0,0 +1,18 @@ +package com.example.parser + +fun parseInts(s: String): List = + s.split(",").mapNotNull { it.trim().toIntOrNull() } + +fun tokenize(s: String): List = + s.split("\\s+".toRegex()).filter { it.isNotEmpty() } + +data class Token(val kind: String, val value: String) + +interface Parseable { + fun parse(input: String): List +} + +class SimpleParser : Parseable { + override fun parse(input: String): List = + tokenize(input).map { Token("word", it) } +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainUtils.kt b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainUtils.kt new file mode 100644 index 00000000..8230e3d0 --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/PlainUtils.kt @@ -0,0 +1,15 @@ +package com.example.utils + +fun add(a: Int, b: Int): Int = a + b + +fun greet(name: String): String = "Hello, $name!" + +fun reverseList(xs: List): List = xs.reversed() + +class Counter(initial: Int) { + private var count = initial + fun increment() { count++ } + fun value(): Int = count +} + +data class Pair(val first: A, val second: B) diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/SpringBootApp.kt b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/SpringBootApp.kt new file mode 100644 index 00000000..ffdcfcb6 --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/kotlin/SpringBootApp.kt @@ -0,0 +1,19 @@ +package com.example.spring + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication +class SpringBootApp + +fun main(args: Array) { + runApplication(*args) +} + +@RestController +class GreetController { + @GetMapping("/hello") + fun hello(): String = "Hello, Spring!" +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/scala/AkkaService.scala b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/AkkaService.scala new file mode 100644 index 00000000..071e7bb1 --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/AkkaService.scala @@ -0,0 +1,18 @@ +package com.example.akka + +import akka.actor.{Actor, ActorSystem, Props} +import akka.actor.ActorRef + +class GreetActor extends Actor { + def receive: Receive = { + case msg: String => println(s"Got: $msg") + } +} + +object AkkaService { + def main(args: Array[String]): Unit = { + val system = ActorSystem("demo") + val ref: ActorRef = system.actorOf(Props[GreetActor](), "greeter") + ref ! "hello" + } +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/scala/CatsEffectApp.scala b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/CatsEffectApp.scala new file mode 100644 index 00000000..e809b16b --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/CatsEffectApp.scala @@ -0,0 +1,12 @@ +package com.example.cats + +import cats.effect.{IO, IOApp} +import cats.effect.ExitCode + +object CatsEffectApp extends IOApp { + def run(args: List[String]): IO[ExitCode] = + IO.println("hello cats").as(ExitCode.Success) + + def fetchData(url: String): IO[String] = + IO.delay(s"response from $url") +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainParser.scala b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainParser.scala new file mode 100644 index 00000000..d96599bb --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainParser.scala @@ -0,0 +1,17 @@ +package com.example.parser + +object PlainParser { + def parseInts(s: String): List[Int] = + s.split(",").toList.flatMap { tok => + tok.trim.toIntOption.toList + } + + def tokenize(s: String): List[String] = + s.split("\\s+").toList.filter(_.nonEmpty) +} + +case class Token(kind: String, value: String) + +trait Parseable { + def parse(input: String): List[Token] +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainUtils.scala b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainUtils.scala new file mode 100644 index 00000000..20a65bbb --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/PlainUtils.scala @@ -0,0 +1,15 @@ +package com.example.utils + +object PlainUtils { + def add(a: Int, b: Int): Int = a + b + + def greet(name: String): String = s"Hello, $name!" + + def reverseList[A](xs: List[A]): List[A] = xs.reverse +} + +class Counter(initial: Int) { + private var count = initial + def increment(): Unit = { count += 1 } + def value: Int = count +} diff --git a/go/testdata/fixture-multi-lang/services/jvm-suite/scala/SlickRepo.scala b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/SlickRepo.scala new file mode 100644 index 00000000..5d26fd1d --- /dev/null +++ b/go/testdata/fixture-multi-lang/services/jvm-suite/scala/SlickRepo.scala @@ -0,0 +1,17 @@ +package com.example.slick + +import slick.jdbc.H2Profile.api._ +import slick.lifted.TableQuery + +class UsersTable(tag: Tag) extends Table[(Int, String)](tag, "USERS") { + def id = column[Int]("ID", O.PrimaryKey) + def name = column[String]("NAME") + def * = (id, name) +} + +object SlickRepo { + val users = TableQuery[UsersTable] + + def insertUser(id: Int, name: String) = + users += (id, name) +}