diff --git a/build.gradle.kts b/build.gradle.kts index bb9ebe7..08681f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,8 +10,9 @@ plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.serialization") version "2.1.0" id("com.diffplug.spotless") version "7.0.3" - id("pl.allegro.tech.build.axion-release") version "1.18.7" + id("io.kotest.multiplatform") version "5.0.2" id("org.jreleaser") version "1.17.0" + id("pl.allegro.tech.build.axion-release") version "1.18.7" } scmVersion { @@ -28,6 +29,7 @@ description = "MCP Server for GitHub Code Repositories Analysis" dependencies { val ktorVersion = "3.0.2" val coroutinesVersion = "1.10.2" + val kotestVersion = "5.9.1" // Kotlin standard library implementation(kotlin("stdlib")) @@ -63,6 +65,10 @@ dependencies { testImplementation("io.mockk:mockk:1.14.2") testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") + + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-property:$kotestVersion") } application { mainClass.set("MainKt") } diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 442babf..8b7f24f 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -37,9 +37,10 @@ data class AppCommand(val type: CommandType, val port: Int) /** * Enum representing the application command type. - * - * STDIO - Standard input/output mode for the MCP server SSE_KTOR - Server-Sent Events mode using the Ktor plugin - * SSE_PLAIN - Server-Sent Events mode using plain configuration UNKNOWN - Unknown or unsupported command + * * STDIO - Standard input/output mode for the MCP server + * * SSE_KTOR - Server-Sent Events mode using the Ktor plugin + * * SSE_PLAIN - Server-Sent Events mode using plain configuration + * * UNKNOWN - Unknown or unsupported command */ enum class CommandType { STDIO, @@ -67,13 +68,13 @@ enum class CommandType { } /** - * Runs the application by parsing arguments, ensuring necessary directories exist, and executing the appropriate server - * command. + * Runs the application by parsing arguments, ensuring the necessary directories exist, and executing the appropriate + * server command. * * @param args Command-line arguments to determine server behavior. * @return Result wrapping Unit, with success if the application runs successfully, or failure with the exception if an * error occurs. - * @throws IOException If required directories cannot be created. + * @throws IOException If the required directories cannot be created. */ fun runApplication(args: Array): Result = runCatching { val logger = LoggerFactory.getLogger("Main") diff --git a/src/main/kotlin/mcp/code/analysis/server/Server.kt b/src/main/kotlin/mcp/code/analysis/server/Server.kt index 31d97e9..b4ef1f8 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Server.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Server.kt @@ -7,15 +7,17 @@ import io.ktor.server.engine.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.sse.* -import io.ktor.sse.ServerSentEvent +import io.ktor.sse.* import io.ktor.util.collections.* import io.modelcontextprotocol.kotlin.sdk.* -import io.modelcontextprotocol.kotlin.sdk.server.* import io.modelcontextprotocol.kotlin.sdk.server.Server as SdkServer -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.server.mcp +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.io.IOException import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered @@ -79,24 +81,41 @@ class Server( routing { sse("/sse") { - launch { - while (true) { - send(ServerSentEvent(event = "heartbeat")) - delay(15_000) - } - } - val transport = SseServerTransport("/message", this) val server = configureServer() - servers[transport.sessionId] = server + val heartbeatJob = launch { + flow { + while (true) { + emit(Unit) + delay(15_000) + } + } + .onEach { send(ServerSentEvent(event = "heartbeat")) } + .catch { e -> + when (e) { + is IOException -> logger.debug("Client disconnected during heartbeat: ${e.message}") + else -> logger.error("Heartbeat error: ${e.message}", e) + } + } + .onCompletion { logger.debug("Heartbeat job terminated for session: ${transport.sessionId}") } + .collect() + } + server.onClose { logger.info("Server closed") servers.remove(transport.sessionId) } server.connect(transport) + + try { + awaitCancellation() + } finally { + heartbeatJob.cancel() + logger.info("SSE connection closed for session: ${transport.sessionId}") + } } post("/message") { diff --git a/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt b/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt index 5ac61b4..af59acb 100644 --- a/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt +++ b/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt @@ -1,9 +1,19 @@ package mcp.code.analysis.service import java.io.File +import kotlin.text.lines import org.slf4j.Logger import org.slf4j.LoggerFactory +data class LanguagePatterns( + val definitionPattern: Regex, + val commentPrefixes: List, + val blockCommentStart: String, + val blockCommentEnd: String, +) + +data class State(val lines: List = emptyList(), val inCommentBlock: Boolean = false) + /** * Responsible for analyzing the structure of a codebase. Identifies files, directories, and their respective metadata * such as size, language, imports, and declarations. @@ -23,37 +33,182 @@ data class CodeAnalyzer( fun analyzeStructure(repoDir: File): Map = processDirectory(repoDir, repoDir.absolutePath) /** - * Collects all code snippets from the repository. + * Collects summarized code snippets from the repository. * * @param repoDir The root directory of the repository - * @return List of code snippets with metadata including file path and language + * @param maxLines The maximum number of lines to include per file summary + * @return List of code summaries with metadata */ - fun collectAllCodeSnippets(repoDir: File): List = + fun collectSummarizedCodeSnippets(repoDir: File, maxLines: Int = 100): List = findCodeFiles(repoDir) .filter { file -> - file.extension.lowercase() in setOf("kt", "java", "scala", "py", "rb", "js", "ts", "go", "c", "cpp", "rust") && + file.extension.lowercase() in setOf("kt", "java", "scala", "py", "rb", "js", "ts", "go", "c", "cpp", "rs") && !file.absolutePath.contains("test", ignoreCase = true) } .map { file -> - val relativePath = file.absolutePath.substring(repoDir.absolutePath.length + 1) + val relativePath = file.absolutePath.removePrefix(repoDir.absolutePath).removePrefix("/") val lang = getLanguageFromExtension(file.extension) - val content = file.readLines().joinToString("\n") - """|--- File: $relativePath - |~~~$lang - |$content - |~~~""" - .trimMargin() + val content = file.readText() + summarizeCodeContent(relativePath, lang, content, maxLines) } - .toList() .also { snippets -> - logger.info("Collected ${snippets.size} code snippets from ${repoDir.absolutePath}") + logger.info("Collected ${snippets.size} summarized snippets from ${repoDir.absolutePath}") logger.debug( """|Snippets Found: - |${snippets.joinToString("\n")}""" + |${snippets.joinToString("\n")} + |""" .trimMargin() ) } + /** + * Summarizes the content of a file. + * + * @param path The path of the file + * @param language The language of the file + * @param content The content of the file + * @param maxLines The maximum number of lines to include in the summary + */ + fun summarizeCodeContent(path: String, language: String, content: String, maxLines: Int = 250): String { + + val patterns = + when (language.lowercase()) { + "kotlin" -> + LanguagePatterns( + Regex( + """(class|interface|object|enum class|data class|sealed class|fun|val|var|const|typealias|annotation class).*""" + ), + listOf("//"), + "/*", + "*/", + ) + + "scala" -> + LanguagePatterns( + Regex( + """(class|object|trait|case class|case object|def|val|var|lazy val|type|implicit|sealed|abstract|override|package object).*""" + ), + listOf("//"), + "/*", + "*/", + ) + + "java" -> + LanguagePatterns( + Regex( + """(class|interface|enum|@interface|record|public|private|protected|static|abstract|final|synchronized|volatile|native|transient|strictfp).*""" + ), + listOf("//"), + "/*", + "*/", + ) + + "python" -> + LanguagePatterns(Regex("""(def|class|async def|@|import|from).*"""), listOf("#"), "\"\"\"", "\"\"\"") + + "ruby" -> + LanguagePatterns( + Regex("""(def|class|module|attr_|require|include|extend).*"""), + listOf("#"), + "=begin", + "=end", + ) + + "javascript", + "typescript" -> + LanguagePatterns( + Regex("""(function|class|const|let|var|import|export|interface|type|enum|namespace).*"""), + listOf("//"), + "/*", + "*/", + ) + + "go" -> + LanguagePatterns( + Regex("""(func|type|struct|interface|package|import|var|const).*"""), + listOf("//"), + "/*", + "*/", + ) + + "rust" -> + LanguagePatterns( + Regex("""(fn|struct|enum|trait|impl|pub|use|mod|const|static|type|async|unsafe).*"""), + listOf("//"), + "/*", + "*/", + ) + + "c", + "cpp" -> + LanguagePatterns( + Regex("""(class|struct|enum|typedef|namespace|template|void|int|char|bool|auto|extern|static|virtual).*"""), + listOf("//"), + "/*", + "*/", + ) + + // Default fallback for other languages + else -> + LanguagePatterns( + Regex("""(class|interface|object|enum|fun|def|function|public|private|protected|static).*"""), + listOf("//", "#"), + "/*", + "*/", + ) + } + + val definitionPattern = patterns.definitionPattern + val commentPrefixes = patterns.commentPrefixes + val blockCommentStart = patterns.blockCommentStart + val blockCommentEnd = patterns.blockCommentEnd + + val isDefinition: (String) -> Boolean = { line -> line.trim().matches(definitionPattern) } + + val isCommentLine: (String) -> Boolean = { line -> + val trimmed = line.trim() + commentPrefixes.any { trimmed.startsWith(it) } || trimmed.startsWith(blockCommentStart) || trimmed.startsWith("*") + } + + val processDefinitionLine: (String) -> String = { line -> + val trimmed = line.trim() + if (trimmed.contains("{") && !trimmed.contains("}")) "$trimmed }" else trimmed + } + + val finalState = + content.lines().fold(State()) { state, line -> + if (state.lines.size >= maxLines) return@fold state + val trimmed = line.trim() + val nextInCommentBlock = + when { + trimmed.startsWith(blockCommentStart) -> true + trimmed.endsWith(blockCommentEnd) -> false + language.lowercase() == "python" && trimmed == "\"\"\"" -> !state.inCommentBlock + else -> state.inCommentBlock + } + + val shouldIncludeLine = isDefinition(line) || isCommentLine(line) || state.inCommentBlock + val updatedLines = + if (shouldIncludeLine) { + if (isDefinition(line)) { + // Apply processing to definition lines to ensure braces are complete + state.lines + processDefinitionLine(line) + } else { + state.lines + line + } + } else state.lines + + State(updatedLines, nextInCommentBlock) + } + + // Ensure we're using the correct file path and language + return """|### File: $path + |~~~$language + |${finalState.lines.joinToString("\n")} + |~~~""" + .trimMargin() + } + /** * Finds the README file in the repository. * diff --git a/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt b/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt index 0acea42..805c970 100644 --- a/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt +++ b/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt @@ -93,9 +93,9 @@ data class ModelContextService( * @return A structured prompt for the model */ fun buildInsightsPrompt(codeSnippets: List, readme: String) = - """|You are analyzing a software codebase that includes a README file and source code files. Your task is to extract a structured, file-by-file analysis of the codebase's architecture, components, and interconnections. + """|You are analyzing a software codebase that includes a README file and source code files. Your task is to extract a **factual, structured summary** of the codebase’s architecture, components, and relationships. | - |Use only the information provided below. + |Use only the information provided below. **Do not assume or invent any technologies, libraries, or architecture styles** unless they are explicitly stated in the content. | |---------------------- |README Content: @@ -110,14 +110,14 @@ data class ModelContextService( |${codeSnippets.joinToString("\n\n")} |---------------------- | - |For each file, extract the following: - |- File path and programming language - |- Main classes, functions, or data structures defined - |- Purpose of the file (what it does and why it exists) - |- Key public interfaces (classes, methods, functions) - |- Dependencies and relationships (e.g., imports, API usage, method calls across files) + |For each file, identify: + |- File name and programming language + |- Main classes, functions, or data structures + |- Purpose of the file (based on code and comments) + |- Key public interfaces (e.g., functions, methods, classes) + |- If applicable, how the file connects to other files (e.g., imports, calls, shared data structures) | - |Output using this format: + |Use this format: | |### File: path/to/file.ext (Language: X) |- **Purpose**: ... @@ -126,10 +126,7 @@ data class ModelContextService( |- **Relationships**: | - ... | - |Important: - |- Be specific and factual; avoid speculation. - |- Use information from comments, function names, or obvious patterns. - |- Summarize concisely; no fluff. + |Repeat for all files. Be concise. Avoid speculation or generalization. Stay grounded in the actual code and README. |""" .trimMargin() @@ -140,30 +137,26 @@ data class ModelContextService( * @return A structured prompt for the model */ fun buildSummaryPrompt(insights: String): String = - """|You are writing a high-level, technically accurate summary of a software codebase. The reader is a developer unfamiliar with the project, so your explanation should clarify the purpose, structure, and important technical details. + """|You are writing a high-level yet technically accurate summary of a software codebase. Your audience is a developer unfamiliar with the project. | - |Use the structured analysis below to guide your summary. + |Use the structural analysis below. **Base your summary strictly on what is stated**—do not speculate or introduce external technologies or patterns unless clearly mentioned. | |---------------------- + | |Structural Analysis: | |${parseInsights(insights)} |---------------------- | - |Include the following in your output: - | - |1. **Main Purpose** – What the software does, the problem it solves, and who uses it. - |2. **Architecture Overview** – Key components or layers and how they interact. Describe patterns if evident (e.g., microservices, event-driven, layered architecture). - |3. **Technologies and Languages** – List the programming language(s), major frameworks/libraries, and any notable tools. - |4. **Key Workflows** – Show how data or control flows through the system. Prefer concrete examples (e.g., "Kafka -> Consumer -> Metrics tracker -> Monitoring system"). - |5. **Strengths and Weaknesses** – Mention any strengths (modularity, patterns, scalability) and issues (tight coupling, complexity, missing docs). + |Include the following sections: | - |Also consider: - |- Noteworthy classes, functions, or modules - |- Signs of testability, extensibility, or maintainability - |- Use of design patterns (Builder, Observer, etc.) + |1. **Main Purpose** – what the software does and its target users. + |2. **Architecture Overview** – actual components and how they interact. + |3. **Technologies and Languages** – only list what's confirmed in the code or README. + |4. **Key Workflows** – describe specific processing or control flows (e.g., "HTTP request -> controller -> database"). + |5. **Strengths and Weaknesses** – mention design trade-offs (e.g., modularity, coupling, extensibility) based on the structure. | - |Write the output in well-structured **Markdown**. Be concise, insightful, and avoid guessing. + |Avoid making assumptions. Use quotes or references to method/class names where helpful. Format the output using Markdown. Be concise and accurate. |""" .trimMargin() diff --git a/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt b/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt index b25f8c7..4dab8f3 100644 --- a/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt +++ b/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt @@ -19,7 +19,7 @@ data class RepositoryAnalysisService( val repoDir = gitService.cloneRepository(repoUrl, branch) val readme = codeAnalyzer.findReadmeFile(repoDir) - val codeSnippets = codeAnalyzer.collectAllCodeSnippets(repoDir) + val codeSnippets = codeAnalyzer.collectSummarizedCodeSnippets(repoDir) val insightsPrompt = modelContextService.buildInsightsPrompt(codeSnippets, readme) val insightsResponse = modelContextService.generateResponse(insightsPrompt) diff --git a/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt b/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt index e15431b..a741e90 100644 --- a/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt +++ b/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt @@ -30,7 +30,7 @@ class ServerTest { every { anyConstructed() .addTool(name = any(), description = any(), inputSchema = any(), handler = capture(toolHandlerSlot)) - } returns Unit // Assuming addTool returns Unit or the mock will handle it + } returns Unit } @AfterEach @@ -40,56 +40,52 @@ class ServerTest { @Test fun `configureServer registers tool and handler processes success`() = runBlocking { + // Arrange val repoUrl = "https://github.com/test/repo" val branch = "main" val expectedSummary = "Analysis successful" - coEvery { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } returns expectedSummary - // Act: Call configureServer to trigger SdkServer instantiation and addTool + // Act serverUnderTest.configureServer() - // Verify that addTool was called on the (mocked) SdkServer with the correct tool name verify { anyConstructed() .addTool( name = eq("analyze-repository"), - description = any(), // Can be more specific if needed - inputSchema = any(), // Can be more specific by creating the expected Tool.Input + description = any(), + inputSchema = any(), handler = toolHandlerSlot.captured, ) } - // Prepare a request for the captured handler val request = CallToolRequest( arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), name = "analyze-repository", ) - - // Invoke the captured handler directly val result = toolHandlerSlot.captured.invoke(request) - // Assert the handler's output + // Assert assertFalse(result.isError == true, "Result should not be an error on success") assertEquals(1, result.content.size) val textContent = result.content.first() as? TextContent assertNotNull(textContent, "Content should be TextContent") assertEquals(expectedSummary, textContent?.text) - - // Verify the service was called correctly coVerify { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } } @Test fun `tool handler processes repository analysis error`() = runBlocking { + // Arrange val repoUrl = "https://github.com/test/repo" val branch = "main" val errorMessage = "Failed to analyze due to network issue" coEvery { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } throws Exception(errorMessage) - serverUnderTest.configureServer() // This captures the handler + // Act + serverUnderTest.configureServer() val request = CallToolRequest( @@ -98,6 +94,7 @@ class ServerTest { ) val result = toolHandlerSlot.captured.invoke(request) + // Assert assertTrue(result.isError == true, "Result should be an error") assertEquals(1, result.content.size) val textContent = result.content.first() as? TextContent @@ -111,21 +108,14 @@ class ServerTest { @Test fun `tool handler processes missing repoUrl argument`() = runBlocking { - serverUnderTest.configureServer() // This captures the handler + // Act + serverUnderTest.configureServer() val request = - CallToolRequest( - arguments = - JsonObject( - mapOf( - // "repoUrl" is intentionally missing - "branch" to JsonPrimitive("main") - ) - ), - name = "analyze-repository", - ) + CallToolRequest(arguments = JsonObject(mapOf("branch" to JsonPrimitive("main"))), name = "analyze-repository") val result = toolHandlerSlot.captured.invoke(request) + // Assert assertTrue(result.isError == true, "Result should be an error for missing repoUrl") assertEquals(1, result.content.size) val textContent = result.content.first() as? TextContent @@ -134,35 +124,25 @@ class ServerTest { textContent?.text?.contains("Error analyzing repository: Missing repoUrl parameter") == true, "Error message for missing repoUrl mismatch. Actual: ${textContent?.text}", ) - - // Ensure the analysis service was not called coVerify(exactly = 0) { repositoryAnalysisService.analyzeRepository(any(), any()) } } @Test fun `tool handler uses default branch if not provided`() = runBlocking { + // Arrange val repoUrl = "https://github.com/test/repo" - val defaultBranch = "main" // As defined in the handler logic + val defaultBranch = "main" val expectedSummary = "Analysis with default branch successful" - - // Expect the service to be called with the default branch coEvery { repositoryAnalysisService.analyzeRepository(repoUrl, defaultBranch) } returns expectedSummary - serverUnderTest.configureServer() // This captures the handler + // Act + serverUnderTest.configureServer() val request = - CallToolRequest( - arguments = - JsonObject( - mapOf( - "repoUrl" to JsonPrimitive(repoUrl) - // "branch" is intentionally missing - ) - ), - name = "analyze-repository", - ) + CallToolRequest(arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl))), name = "analyze-repository") val result = toolHandlerSlot.captured.invoke(request) + // Assert assertFalse(result.isError == true, "Result should not be an error when using default branch") assertEquals(1, result.content.size) val textContent = result.content.first() as? TextContent diff --git a/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerPropertyTest.kt b/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerPropertyTest.kt new file mode 100644 index 0000000..87b5e8c --- /dev/null +++ b/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerPropertyTest.kt @@ -0,0 +1,510 @@ +package mcp.code.analysis.service + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.kotest.property.Arb +import io.kotest.property.arbitrary.* +import io.kotest.property.checkAll +import io.mockk.mockk +import org.slf4j.Logger + +class CodeAnalyzerPropertyTest : + StringSpec({ + val logger: Logger = mockk(relaxed = true) + val analyzer = CodeAnalyzer(logger = logger) + + // Generate valid file paths + fun getPathGenerator(language: String) = + Arb.string(1..50).map { base -> + val validPath = base.replace(Regex("[^a-zA-Z0-9/._-]"), "_") + val extension = + when (language) { + "kotlin" -> ".kt" + "java" -> ".java" + "scala" -> ".scala" + "python" -> ".py" + "ruby" -> ".rb" + "javascript" -> ".js" + "typescript" -> ".ts" + "go" -> ".go" + "c" -> ".c" + "cpp" -> ".cpp" + "rust" -> ".rs" + else -> ".txt" + } + if (validPath.lowercase().endsWith(extension)) validPath else "$validPath$extension" + } + + // Generate language constants + val languageGenerator = arbitrary { + listOf("kotlin", "java", "scala", "python", "ruby", "javascript", "typescript", "go", "c", "cpp", "rust").random() + } + + // Generate code content for different languages + fun generateCodeForLanguage(language: String): String { + return when (language) { + "kotlin" -> + """ + package test + + // This is a comment + /* Block comment + with multiple lines */ + class TestClass { + fun testMethod() { + // Method comment + val x = 1 + } + } + + object Singleton { + val constant = 42 + } + """ + .trimIndent() + + "java" -> + """ + package test; + + // This is a comment + /* Block comment + with multiple lines */ + public class TestClass { + // Field comment + private int field; + + public void testMethod() { + // Method comment + int x = 1; + } + } + + interface TestInterface { + void testMethod(); + } + """ + .trimIndent() + + "scala" -> + """ + package test + + // This is a comment + /* Block comment + with multiple lines */ + class TestClass { + def testMethod(): Unit = { + // Method comment + val x = 1 + } + } + + object Singleton { + val constant = 42 + } + + trait TestTrait { + def abstractMethod(): Unit + } + """ + .trimIndent() + + "python" -> + """ + # This is a comment + + \"\"\" + Block comment + with multiple lines + \"\"\" + + def test_function(): + # Function comment + x = 1 + + class TestClass: + \"\"\"Class docstring\"\"\" + def __init__(self): + self.value = 42 + + def method(self): + return self.value + """ + .trimIndent() + + "ruby" -> + """ + # This is a comment + + =begin + Block comment + with multiple lines + =end + + def test_method + # Method comment + x = 1 + end + + class TestClass + def initialize + @value = 42 + end + + def method + @value + end + end + + module TestModule + def self.module_method + puts "Hello" + end + end + """ + .trimIndent() + + "javascript", + "typescript" -> + """ + // This is a comment + + /* Block comment + with multiple lines */ + + function testFunction() { + // Function comment + const x = 1; + } + + class TestClass { + constructor() { + this.value = 42; + } + + method() { + return this.value; + } + } + + const arrowFn = () => { + return "Hello"; + }; + """ + .trimIndent() + + "go" -> + """ + package test + + // This is a comment + + /* Block comment + with multiple lines */ + + func testFunction() { + // Function comment + x := 1 + } + + type TestStruct struct { + Value int + } + + func (t TestStruct) Method() int { + return t.Value + } + + type TestInterface interface { + Method() int + } + """ + .trimIndent() + + "c", + "cpp" -> + """ + // This is a comment + + /* Block comment + with multiple lines */ + + void testFunction() { + // Function comment + int x = 1; + } + + struct TestStruct { + int value; + }; + + class TestClass { + public: + TestClass() : value(42) {} + + int method() { + return value; + } + + private: + int value; + }; + """ + .trimIndent() + + "rust" -> + """ + // This is a comment + + /* Block comment + with multiple lines */ + + fn test_function() { + // Function comment + let x = 1; + } + + struct TestStruct { + value: i32, + } + + impl TestStruct { + fn new() -> Self { + TestStruct { value: 42 } + } + + fn method(&self) -> i32 { + self.value + } + } + + trait TestTrait { + fn trait_method(&self) -> i32; + } + """ + .trimIndent() + + else -> + """ + // Default comment + + /* Default block comment */ + + function defaultFunction() { + // Function comment + } + + class DefaultClass { + method() {} + } + """ + .trimIndent() + } + } + + "summarizeCodeContent should correctly extract definitions and comments for all languages" { + checkAll(100, languageGenerator) { language -> + checkAll(100, getPathGenerator(language)) { path -> + val content = generateCodeForLanguage(language) + val maxLines = 50 + + val result = analyzer.summarizeCodeContent(path, language, content, maxLines) + + // Check the output format + result.shouldStartWith("### File: $path") + result.shouldContain("~~~$language") + result.shouldContain("~~~") + + // Check content extraction based on language + when (language) { + "kotlin" -> { + result.shouldContain("class TestClass") + result.shouldContain("fun testMethod") + result.shouldContain("object Singleton") + result.shouldContain("// This is a comment") + result.shouldContain("/* Block comment") + } + "java" -> { + result.shouldContain("public class TestClass") + result.shouldContain("interface TestInterface") + result.shouldContain("// This is a comment") + result.shouldContain("/* Block comment") + } + "scala" -> { + result.shouldContain("class TestClass") + result.shouldContain("def testMethod") + result.shouldContain("object Singleton") + result.shouldContain("trait TestTrait") + result.shouldContain("// This is a comment") + } + "python" -> { + result.shouldContain("def test_function") + result.shouldContain("class TestClass") + result.shouldContain("# This is a comment") + } + "ruby" -> { + result.shouldContain("def test_method") + result.shouldContain("class TestClass") + result.shouldContain("module TestModule") + result.shouldContain("# This is a comment") + } + "javascript", + "typescript" -> { + result.shouldContain("function testFunction") + result.shouldContain("class TestClass") + result.shouldContain("const arrowFn") + result.shouldContain("// This is a comment") + } + "go" -> { + result.shouldContain("func testFunction") + result.shouldContain("type TestStruct struct") + result.shouldContain("type TestInterface interface") + result.shouldContain("// This is a comment") + } + "c", + "cpp" -> { + result.shouldContain("void testFunction") + result.shouldContain("struct TestStruct") + result.shouldContain("class TestClass") + result.shouldContain("// This is a comment") + } + "rust" -> { + result.shouldContain("fn test_function") + result.shouldContain("struct TestStruct") + result.shouldContain("impl TestStruct") + result.shouldContain("trait TestTrait") + result.shouldContain("// This is a comment") + } + } + } + } + } + + "summarizeCodeContent should respect maxLines parameter" { + checkAll(100, languageGenerator) { language -> + checkAll(100, getPathGenerator(language)) { path -> + val maxLines = 50 + val largeContent = generateCodeForLanguage(language).repeat(10) + val result = analyzer.summarizeCodeContent(path, language, largeContent, maxLines) + + val resultLines = result.lines().filter { !it.startsWith("### File:") && !it.matches(Regex("^~~~.*$")) } + assert(resultLines.size <= maxLines) { + "Result should have at most $maxLines lines, but had ${resultLines.size} instead" + } + } + } + } + + "summarizeCodeContent should handle empty content" { + checkAll(100, languageGenerator) { language -> + checkAll(100, getPathGenerator(language)) { path -> + val result = analyzer.summarizeCodeContent(path, language, "", 100) + + result.shouldStartWith("### File: $path") + result.shouldContain("~~~$language") + result.shouldContain("~~~") + } + } + } + + "summarizeCodeContent should handle content with only comments" { + checkAll(100, languageGenerator) { language -> + checkAll(100, getPathGenerator(language)) { path -> + val commentOnlyContent = + when (language) { + "python", + "ruby" -> "# This is only a comment\n# Another comment line" + + else -> "// This is only a comment\n// Another comment line" + } + + val result = analyzer.summarizeCodeContent(path, language, commentOnlyContent, 100) + + result.shouldContain(commentOnlyContent) + } + } + } + + "summarizeCodeContent should handle content with only definitions" { + checkAll(100, languageGenerator) { language -> + checkAll(100, getPathGenerator(language)) { path -> + val definitionOnlyContent = + when (language) { + "kotlin" -> "class Test {}\nfun testMethod() {}" + "java" -> "public class Test {}\npublic void testMethod() {}" + "scala" -> "class Test {}\ndef testMethod() {}" + "python" -> "class Test:\n pass\ndef test_function():\n pass" + "ruby" -> "class Test\nend\ndef test_method\nend" + "javascript", + "typescript" -> "class Test {}\nfunction testMethod() {}" + + "go" -> "type Test struct {}\nfunc testFunction() {}" + "c", + "cpp" -> "struct Test {};\nvoid testFunction() {}" + + "rust" -> "struct Test {}\nfn test_function() {}" + else -> "class Test {}\nfunction testMethod() {}" + } + + val result = analyzer.summarizeCodeContent(path, language, definitionOnlyContent, 100) + + // The definitions should be included in the summary + when (language) { + "kotlin" -> { + result.shouldContain("class Test") + result.shouldContain("fun testMethod") + } + + "java" -> { + result.shouldContain("public class Test") + result.shouldContain("public void testMethod") + } + + "scala" -> { + result.shouldContain("class Test") + result.shouldContain("def testMethod") + } + + "python" -> { + result.shouldContain("class Test") + result.shouldContain("def test_function") + } + + "ruby" -> { + result.shouldContain("class Test") + result.shouldContain("def test_method") + } + + "javascript", + "typescript" -> { + result.shouldContain("class Test") + result.shouldContain("function testMethod") + } + + "go" -> { + result.shouldContain("type Test struct") + result.shouldContain("func testFunction") + } + + "c", + "cpp" -> { + result.shouldContain("struct Test") + result.shouldContain("void testFunction") + } + + "rust" -> { + result.shouldContain("struct Test") + result.shouldContain("fn test_function") + } + + else -> { + result.shouldContain("class Test") + result.shouldContain("function testMethod") + } + } + } + } + } + }) diff --git a/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt b/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt index 0529f4a..7577a6a 100644 --- a/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt +++ b/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt @@ -195,7 +195,7 @@ class CodeAnalyzerTest { } // Act - val snippets = analyzer.collectAllCodeSnippets(tempDir) + val snippets = analyzer.collectSummarizedCodeSnippets(tempDir) // Assert assertEquals( diff --git a/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt b/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt index 06341af..e88ccab 100644 --- a/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt +++ b/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt @@ -52,7 +52,7 @@ class RepositoryAnalysisServiceTest { // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets + every { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } returns codeSnippets every { modelContextService.buildInsightsPrompt(codeSnippets, readme) } returns insightsPrompt coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt @@ -65,7 +65,7 @@ class RepositoryAnalysisServiceTest { assertEquals(summaryResponse, result) verify { gitService.cloneRepository(repoUrl, branch) } verify { codeAnalyzer.findReadmeFile(clonedRepo) } - verify { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } + verify { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } verify { modelContextService.buildInsightsPrompt(codeSnippets, readme) } verify { modelContextService.buildSummaryPrompt(insightsResponse) } } @@ -96,7 +96,7 @@ class RepositoryAnalysisServiceTest { every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns "README content" - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } throws Exception(errorMessage) + every { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } throws Exception(errorMessage) // Act & Assert val exception = assertThrows { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } @@ -118,7 +118,7 @@ class RepositoryAnalysisServiceTest { every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets + every { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } returns codeSnippets every { modelContextService.buildInsightsPrompt(codeSnippets, readme) } returns insightsPrompt coEvery { modelContextService.generateResponse(insightsPrompt) } throws Exception(errorMessage) @@ -145,7 +145,7 @@ class RepositoryAnalysisServiceTest { // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns emptyCodeSnippets + every { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } returns emptyCodeSnippets every { modelContextService.buildInsightsPrompt(emptyCodeSnippets, readme) } returns insightsPrompt coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt @@ -156,7 +156,7 @@ class RepositoryAnalysisServiceTest { // Assert assertEquals(summaryResponse, result) - verify { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } + verify { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } verify { modelContextService.buildInsightsPrompt(emptyCodeSnippets, readme) } } @@ -176,7 +176,7 @@ class RepositoryAnalysisServiceTest { // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns noReadme - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets + every { codeAnalyzer.collectSummarizedCodeSnippets(clonedRepo) } returns codeSnippets every { modelContextService.buildInsightsPrompt(codeSnippets, noReadme) } returns insightsPrompt coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt