Skip to content

Commit e544af0

Browse files
aksOpsclaude
andauthored
fix(detector/jvm): discriminator guard on KtorRouteDetector + scala/kotlin fixtures (#141)
Earlier parity runs showed Kotlin ~14× over-detection vs the Java reference. Root cause: KtorRouteDetector matched `routing {}`, `get("...") {}`, `post("...") {}`, and `install(...)` on ANY Kotlin file regardless of imports — generic DSL patterns that appear in non-Ktor code (custom routing DSLs, test helpers, coroutine builders). Adds: - `io.ktor` import discriminator guard to KtorRouteDetector: returns EmptyResult immediately if no io.ktor import is present in the file - Negative tests: TestKtorRoutesNoFireOnPlainDSL (realistic plain Kotlin with Ktor-shaped patterns but no import) and TestKtorRoutesNoFireOnFixturePlainUtils - Scala structures detector confirmed clean: emits only class/interface/method nodes; TestScalaStructuresNoFrameworkEmissions added to lock this in - Minimal Scala + Kotlin fixtures under testdata/.../jvm-suite/ covering Akka, Cats Effect, Slick, Ktor, Spring Boot, Exposed, plus plain files All 831 tests pass; 5 parity tests pass. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 531e089 commit e544af0

13 files changed

Lines changed: 269 additions & 0 deletions

File tree

go/internal/detector/jvm/kotlin/ktor_routes.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import (
1212
// KtorRouteDetector mirrors Java KtorRouteDetector regex tier. Detects
1313
// `routing { get("/p") { } }` blocks, `route("/api") {` prefixes,
1414
// `authenticate("...") {` guards, and `install(...)` features.
15+
//
16+
// REQUIRES a Ktor-specific discriminator import (`io.ktor`) to avoid false
17+
// positives on plain Kotlin code that uses similar DSL idioms (e.g. custom
18+
// routing DSLs, test fixtures, or coroutine builders named `routing`/`get`).
1519
type KtorRouteDetector struct{}
1620

1721
func NewKtorRouteDetector() *KtorRouteDetector { return &KtorRouteDetector{} }
@@ -71,6 +75,14 @@ func (d KtorRouteDetector) Detect(ctx *detector.Context) *detector.Result {
7175
if text == "" {
7276
return detector.EmptyResult()
7377
}
78+
79+
// Discriminator: require an io.ktor import to avoid false positives on
80+
// plain Kotlin code that uses DSL patterns (routing {}, get("...") {})
81+
// without any Ktor dependency.
82+
if !strings.Contains(text, "io.ktor") {
83+
return detector.EmptyResult()
84+
}
85+
7486
fp := ctx.FilePath
7587
var nodes []*model.CodeNode
7688

go/internal/detector/jvm/kotlin/ktor_routes_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,50 @@ func TestKtorRoutesNegative(t *testing.T) {
9090
}
9191
}
9292

93+
// TestKtorRoutesNoFireOnPlainDSL verifies the import discriminator prevents false
94+
// positives when plain Kotlin code uses DSL patterns that visually resemble Ktor
95+
// (routing {}, get("/path") {}, install(...)) but has no io.ktor import.
96+
func TestKtorRoutesNoFireOnPlainDSL(t *testing.T) {
97+
d := NewKtorRouteDetector()
98+
plainWithDSL := `package com.example
99+
100+
fun runTest() {
101+
routing { println("not ktor") }
102+
get("/fake") { doSomething() }
103+
post("/fake") { doSomething() }
104+
install(Something)
105+
}
106+
`
107+
ctx := &detector.Context{FilePath: "src/PlainUtils.kt", Language: "kotlin", Content: plainWithDSL}
108+
r := d.Detect(ctx)
109+
if len(r.Nodes) != 0 {
110+
t.Fatalf("expected 0 nodes on plain Kotlin DSL without io.ktor import, got %d nodes", len(r.Nodes))
111+
}
112+
}
113+
114+
// TestKtorRoutesNoFireOnFixturePlainUtils verifies zero framework emissions on
115+
// the PlainUtils.kt fixture (stdlib only, no framework imports).
116+
func TestKtorRoutesNoFireOnFixturePlainUtils(t *testing.T) {
117+
d := NewKtorRouteDetector()
118+
plainUtils := `package com.example.utils
119+
120+
fun add(a: Int, b: Int): Int = a + b
121+
122+
fun greet(name: String): String = "Hello, $name!"
123+
124+
class Counter(initial: Int) {
125+
private var count = initial
126+
fun increment() { count++ }
127+
fun value(): Int = count
128+
}
129+
`
130+
ctx := &detector.Context{FilePath: "src/PlainUtils.kt", Language: "kotlin", Content: plainUtils}
131+
r := d.Detect(ctx)
132+
if len(r.Nodes) != 0 {
133+
t.Fatalf("expected 0 framework nodes on PlainUtils.kt, got %d", len(r.Nodes))
134+
}
135+
}
136+
93137
func TestKtorRoutesDeterminism(t *testing.T) {
94138
d := NewKtorRouteDetector()
95139
ctx := &detector.Context{FilePath: "src/Routes.kt", Language: "kotlin", Content: ktorRoutesSample}

go/internal/detector/jvm/scala/scala_structures_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,44 @@ func TestScalaStructuresNegative(t *testing.T) {
121121
}
122122
}
123123

124+
// TestScalaStructuresNoFrameworkEmissions verifies the structures detector emits
125+
// ONLY structural nodes (class/interface/method) on plain Scala files — no
126+
// framework-flavored (endpoint, middleware, guard) nodes regardless of content.
127+
func TestScalaStructuresNoFrameworkEmissions(t *testing.T) {
128+
d := NewScalaStructuresDetector()
129+
plainUtils := `package com.example.utils
130+
131+
object PlainUtils {
132+
def add(a: Int, b: Int): Int = a + b
133+
def greet(name: String): String = s"Hello, $name!"
134+
}
135+
136+
class Counter(initial: Int) {
137+
private var count = initial
138+
def increment(): Unit = { count += 1 }
139+
def value: Int = count
140+
}
141+
`
142+
ctx := &detector.Context{FilePath: "PlainUtils.scala", Language: "scala", Content: plainUtils}
143+
r := d.Detect(ctx)
144+
for _, n := range r.Nodes {
145+
switch n.Kind {
146+
case model.NodeClass, model.NodeInterface, model.NodeMethod:
147+
// expected structural nodes — OK
148+
default:
149+
t.Errorf("unexpected framework node kind %q (id=%q) on plain Scala file", n.Kind, n.ID)
150+
}
151+
}
152+
for _, e := range r.Edges {
153+
switch e.Kind {
154+
case model.EdgeImports, model.EdgeExtends, model.EdgeImplements:
155+
// expected structural edges — OK
156+
default:
157+
t.Errorf("unexpected framework edge kind %q on plain Scala file", e.Kind)
158+
}
159+
}
160+
}
161+
124162
func TestScalaStructuresDeterminism(t *testing.T) {
125163
d := NewScalaStructuresDetector()
126164
ctx := &detector.Context{FilePath: "src/A.scala", Language: "scala", Content: scalaStructuresSample}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.example.exposed
2+
3+
import org.jetbrains.exposed.sql.Table
4+
import org.jetbrains.exposed.sql.insert
5+
import org.jetbrains.exposed.sql.selectAll
6+
import org.jetbrains.exposed.sql.transactions.transaction
7+
8+
object Users : Table("users") {
9+
val id = integer("id").autoIncrement()
10+
val name = varchar("name", 128)
11+
override val primaryKey = PrimaryKey(id)
12+
}
13+
14+
fun insertUser(name: String) = transaction {
15+
Users.insert { it[Users.name] = name }
16+
}
17+
18+
fun listUsers(): List<String> = transaction {
19+
Users.selectAll().map { it[Users.name] }
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.example.ktor
2+
3+
import io.ktor.server.application.*
4+
import io.ktor.server.routing.*
5+
import io.ktor.server.response.*
6+
import io.ktor.server.engine.embeddedServer
7+
import io.ktor.server.netty.Netty
8+
9+
fun Application.module() {
10+
routing {
11+
route("/api") {
12+
get("/health") {
13+
call.respondText("OK")
14+
}
15+
post("/echo") {
16+
call.respondText("echo")
17+
}
18+
}
19+
}
20+
}
21+
22+
fun main() {
23+
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.example.parser
2+
3+
fun parseInts(s: String): List<Int> =
4+
s.split(",").mapNotNull { it.trim().toIntOrNull() }
5+
6+
fun tokenize(s: String): List<String> =
7+
s.split("\\s+".toRegex()).filter { it.isNotEmpty() }
8+
9+
data class Token(val kind: String, val value: String)
10+
11+
interface Parseable {
12+
fun parse(input: String): List<Token>
13+
}
14+
15+
class SimpleParser : Parseable {
16+
override fun parse(input: String): List<Token> =
17+
tokenize(input).map { Token("word", it) }
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.example.utils
2+
3+
fun add(a: Int, b: Int): Int = a + b
4+
5+
fun greet(name: String): String = "Hello, $name!"
6+
7+
fun <A> reverseList(xs: List<A>): List<A> = xs.reversed()
8+
9+
class Counter(initial: Int) {
10+
private var count = initial
11+
fun increment() { count++ }
12+
fun value(): Int = count
13+
}
14+
15+
data class Pair<A, B>(val first: A, val second: B)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.example.spring
2+
3+
import org.springframework.boot.autoconfigure.SpringBootApplication
4+
import org.springframework.boot.runApplication
5+
import org.springframework.web.bind.annotation.GetMapping
6+
import org.springframework.web.bind.annotation.RestController
7+
8+
@SpringBootApplication
9+
class SpringBootApp
10+
11+
fun main(args: Array<String>) {
12+
runApplication<SpringBootApp>(*args)
13+
}
14+
15+
@RestController
16+
class GreetController {
17+
@GetMapping("/hello")
18+
fun hello(): String = "Hello, Spring!"
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.example.akka
2+
3+
import akka.actor.{Actor, ActorSystem, Props}
4+
import akka.actor.ActorRef
5+
6+
class GreetActor extends Actor {
7+
def receive: Receive = {
8+
case msg: String => println(s"Got: $msg")
9+
}
10+
}
11+
12+
object AkkaService {
13+
def main(args: Array[String]): Unit = {
14+
val system = ActorSystem("demo")
15+
val ref: ActorRef = system.actorOf(Props[GreetActor](), "greeter")
16+
ref ! "hello"
17+
}
18+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.cats
2+
3+
import cats.effect.{IO, IOApp}
4+
import cats.effect.ExitCode
5+
6+
object CatsEffectApp extends IOApp {
7+
def run(args: List[String]): IO[ExitCode] =
8+
IO.println("hello cats").as(ExitCode.Success)
9+
10+
def fetchData(url: String): IO[String] =
11+
IO.delay(s"response from $url")
12+
}

0 commit comments

Comments
 (0)