diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c0db651..fdcbf3346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,7 @@ ### Spring MCP +- fix: Name a trace node of a Kotlin companion member in `explyt_trace_spring_call_chain` after its outer class, as the call targets already are (#591) - feat: Bound the answers of `explyt_find_spring_endpoint`, `explyt_get_spring_http_endpoints` and `explyt_get_spring_endpoint_contract` by `maxChars` and continue them with `offset` and `expectedRevision`, as the bean, entity and trace tools do: every current key stays and `status`, `revision`, `offset` and `nextOffset` are added, a continuation of another query or of an edited project answers `RESULT_CHANGED`, one endpoint larger than the budget answers `RESPONSE_TOO_LARGE` naming the budget that fetches it, the neighbourhood of a miss is kept on the first page within the budget with `nearestByPrefixTruncated` when cut, and the listing comes in a stable order so pages never overlap (#635) - docs: Explain in the descriptions of `explyt_find_spring_endpoint`, `explyt_get_spring_endpoint_contract` and `explyt_get_spring_http_endpoints` that `httpMethods` is `["ALL"]` for a route that answers every HTTP method and that such a route passes every value of the `httpMethod` filter of the find and contract tools (#653) - fix: List the test requests of every verb in 'testUrlReferences' of `explyt_trace_spring_call_chain` for a functional route that answers every HTTP method, as for a bare `@RequestMapping` handler (#659) diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt index 8c6e45d1d..b63273bba 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt @@ -7,6 +7,7 @@ package com.explyt.spring.ai.mcp import com.intellij.psi.PsiClass import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.asJava.elements.KtLightMethod import org.jetbrains.kotlin.psi.KtClassOrObject @@ -16,8 +17,7 @@ import org.jetbrains.kotlin.psi.KtObjectDeclaration internal object SourceNames { fun shortNameOf(method: PsiMethod): String { - val declaration = kotlinOwnerOf(method) - val owner = if (declaration is KtObjectDeclaration && declaration.isCompanion()) { + val owner = if (isCompanionMember(method)) { sourceOwnerOf(method)?.name ?: method.containingClass?.name ?: "?" } else { method.containingClass?.name ?: "?" @@ -33,6 +33,18 @@ internal object SourceNames { return "$owner.${sourceNameOf(method)}" } + fun qualifiedOwnerNameOf(method: PsiMethod): String? { + val containingClass = method.containingClass ?: return null + if (method.hasModifierProperty(PsiModifier.STATIC)) return containingClass.qualifiedName + if (isCompanionMember(method)) { + return sourceOwnerOf(method)?.fqName?.asString() ?: containingClass.qualifiedName + } + return containingClass.qualifiedName + } + + private fun isCompanionMember(method: PsiMethod): Boolean = + (kotlinOwnerOf(method) as? KtObjectDeclaration)?.isCompanion() == true + private fun sourceOwnerOf(method: PsiMethod): KtClassOrObject? { val declaration = kotlinOwnerOf(method) ?: return null if (declaration !is KtObjectDeclaration || !declaration.isCompanion()) return declaration diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt index 3873beac8..255b77591 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt @@ -1736,7 +1736,7 @@ class SpringBootApplicationMcpToolset : McpToolset { id = id, layer = containingClass?.let { detectSpringLayer(it) }, reachedBy = traced.reachedBy?.name, - className = containingClass?.qualifiedName ?: containingClass?.name, + className = SourceNames.qualifiedOwnerNameOf(method) ?: containingClass?.name, methodName = SourceNames.sourceNameOf(method), filePath = position.filePath, library = position.library, diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt index 78257515c..c2d375905 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt @@ -344,19 +344,39 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun testCompanionCallsLinkToDistinctOuterClassNodes() = runBlockingReleasingWriteIntent { val chain = traceCompanion("twoParsers") + val companion = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.VmId.Companion", GlobalSearchScope.projectScope(project))!! + val parse = companion.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + val sourceName = SourceNames.qualifiedNameOf(parse)!! + assertTrue("The fixture must expose a light-class owner distinct from its source owner", sourceName.substringBeforeLast(".") != companion.qualifiedName) + assertFalse("The source owner must not use a JVM light-class separator", sourceName.any { it == 36.toChar() }) val calls = chain[0]["callsInto"].filter { it["target"].asText().endsWith(".parse") } assertEquals("Both companion calls must be present in callsInto", 2, calls.size) assertTrue("Both calls must link to expanded nodes: $calls", calls.all { it["node"].isIntegralNumber }) val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } } assertEquals(2, nodes.map { it["id"].asInt() }.distinct().size) assertEquals( - setOf("explyt.trace.VmId.Companion", "explyt.trace.OtherVmId.Companion"), + setOf("explyt.trace.VmId", "explyt.trace.OtherVmId"), nodes.map { it["className"].asText() }.toSet() ) assertEquals(listOf("parse", "parse"), nodes.map { it["methodName"].asText() }) assertTrue(nodes.all { it["filePath"].asText().endsWith("Ids.kt") }) } + fun testCompanionInsideClassNamedCompanionUsesTheClassSourceName() = runBlockingReleasingWriteIntent { + val chain = traceCompanion("classNamedCompanionParse") + val call = companionCall(chain, "parse") + assertTrue("The companion call must be expanded", call["node"].isIntegralNumber) + val lightClass = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.Companion.Companion", GlobalSearchScope.projectScope(project))!! + val parse = lightClass.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + assertEquals("Companion", lightClass.name) + assertTrue("The callee must be the companion of a class named Companion", lightClass.qualifiedName!!.endsWith(".Companion.Companion")) + assertEquals("explyt.trace.Companion", SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast(".")) + val node = chain.single { it["id"].asInt() == call["node"].asInt() } + assertEquals("explyt.trace.Companion", node["className"].asText()) + } + fun testNestedCompanionCallNamesItsImmediateOuterClass() = runBlockingReleasingWriteIntent { val chain = traceCompanion("nestedCompanionParse") val call = companionCall(chain, "parse") @@ -364,7 +384,13 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture assertEquals("PROJECT", call["kind"].asText()) assertTrue("The nested companion must be expanded", call["node"].isIntegralNumber) val node = chain.single { it["id"].asInt() == call["node"].asInt() } - assertEquals("explyt.trace.Outer.InnerId.Companion", node["className"].asText()) + val companion = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.Outer.InnerId.Companion", GlobalSearchScope.projectScope(project))!! + val parse = companion.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + val expectedClassName = SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast(".") + assertTrue("The source class name must differ from the light-class name", expectedClassName != companion.qualifiedName) + assertFalse("The source class name must not contain a JVM separator", expectedClassName.any { it == 36.toChar() }) + assertEquals(expectedClassName, node["className"].asText()) } fun testCompanionNameFallsBackToClassNavigationWithoutKotlinMethodOrigin() = runBlockingReleasingWriteIntent { @@ -402,14 +428,14 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture assertEquals("explyt.trace.Owner.Companion.Nested", node["className"].asText()) } - fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodes() = runBlockingReleasingWriteIntent { + fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodesWithTheSourceClassName() = runBlockingReleasingWriteIntent { val chain = traceCompanion("bothStaticParsers", javaCaller = true) val calls = chain[0]["callsInto"].toList() assertEquals(listOf("StaticVmId.parse", "StaticVmId.parse"), calls.map { it["target"].asText() }) assertTrue(calls.all { it["node"].isIntegralNumber }) assertEquals(2, calls.map { it["node"].asInt() }.distinct().size) val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } } - assertEquals(setOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId.Companion"), nodes.map { it["className"].asText() }.toSet()) + assertEquals(listOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId"), nodes.map { it["className"].asText() }) } private fun companionCall(chain: JsonNode, methodName: String): JsonNode { @@ -437,6 +463,11 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun parse(raw: String): String = raw } } + class Companion { + companion object { + fun parse(raw: String): String = raw + } + } class StaticVmId { companion object { @JvmStatic @@ -475,6 +506,7 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun objectParse(raw: String): String = IdFormat.parse(raw) fun nestedParse(raw: String): String = Outer.Inner().parse(raw) fun nestedCompanionParse(raw: String): String = Outer.InnerId.parse(raw) + fun classNamedCompanionParse(raw: String): String = Companion.parse(raw) fun twoParsers(raw: String): String = VmId.parse(raw) + OtherVmId.parse(raw) } """.trimIndent()