diff --git a/README.md b/README.md index aece133..6b8bff8 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ A Kotlin server application that analyzes GitHub repositories using AI models th ### Prerequisites - JDK 23 or higher -- Kotlin 1.9.x -- Gradle 8.14 or higher +- Kotlin 2.2.x +- Gradle 9.0 or higher - [Ollama](https://github.com/ollama/ollama) 3.2 or higher (for model API) - [MCP Inspector](https://github.com/modelcontextprotocol/inspector) (for model context protocol) @@ -32,13 +32,13 @@ A Kotlin server application that analyzes GitHub repositories using AI models th 3. Start Ollama server: ```bash - ollama run llama3.2 + ollama run llama3.2:latest ``` 4. Start the MCP Inspector: ```bash - npx @modelcontextprotocol/inspector + export DANGEROUSLY_OMIT_AUTH=true; npx @modelcontextprotocol/inspector@0.16.2 ``` 5. You can access the MCP Inspector at `http://127.0.0.1:6274/` and configure the `Arguments` to start the server: @@ -114,10 +114,11 @@ Optional parameters: - `config/`: Configuration classes - `AppConfig.kt`: Immutable configuration data class - `server/`: MCP server implementation - - `Server.kt`: Functional MCP server with multiple run modes + - `Mcp.kt`: Functional MCP server with multiple run modes +- `processor/`: MCP server implementation + - `CodeAnalyzer.kt`: Analyzes code structure - `service/`: Core services for repository analysis - `GitService.kt`: Handles repository cloning - - `CodeAnalyzer.kt`: Analyzes code structure - `ModelContextService.kt`: Generates insights using AI models - `RepositoryAnalysisService.kt`: Coordinates the analysis process diff --git a/build.gradle.kts b/build.gradle.kts index 1277f98..2eaf1e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,10 +7,10 @@ plugins { `maven-publish` signing jacoco - kotlin("jvm") version "2.1.0" - kotlin("plugin.serialization") version "2.1.0" + kotlin("jvm") version "2.2.10" + kotlin("plugin.serialization") version "2.2.10" id("com.diffplug.spotless") version "7.0.3" - id("io.kotest.multiplatform") version "5.0.2" + id("io.kotest.multiplatform") version "5.9.1" id("org.jreleaser") version "1.17.0" id("pl.allegro.tech.build.axion-release") version "1.18.7" } @@ -27,9 +27,10 @@ version = rootProject.scmVersion.version description = "MCP Server for GitHub Code Repositories Analysis" dependencies { - val ktorVersion = "3.0.2" + val ktorVersion = "3.2.0" val coroutinesVersion = "1.10.2" val kotestVersion = "5.9.1" + val mcpKotlinSdk = "0.6.0" // Kotlin standard library implementation(kotlin("stdlib")) @@ -45,7 +46,7 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") // MCP SDK - implementation("io.modelcontextprotocol:kotlin-sdk:0.5.0") + implementation("io.modelcontextprotocol:kotlin-sdk:$mcpKotlinSdk") // Logging implementation("ch.qos.logback:logback-classic:1.5.18") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9..9bbc975 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..2a84e18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f3b75f3..faf9300 100755 --- a/gradlew +++ b/gradlew @@ -205,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. diff --git a/settings.gradle.kts b/settings.gradle.kts index ed8093a..69ba540 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,6 @@ import org.gradle.kotlin.dsl.maven -plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } +plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } rootProject.name = "mcp-github-code-analyzer" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 8b7f24f..3ed2d20 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -2,7 +2,7 @@ import java.io.IOException import java.nio.file.Path import java.nio.file.Paths import mcp.code.analysis.config.AppConfig -import mcp.code.analysis.server.Server +import mcp.code.analysis.server.Mcp import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -88,7 +88,7 @@ fun runApplication(args: Array): Result = runCatching { logger.error("Failed to create necessary directories, check previous errors") throw IOException("Failed to create one or more required directories") } - executeCommand(command, Server()) + executeCommand(command, Mcp()) } /** @@ -160,13 +160,13 @@ fun createDirectoryWithFullPath(path: Path): Result> = runCa * @param server The Server instance to use for execution. * @throws IllegalArgumentException If the command type is UNKNOWN. */ -fun executeCommand(command: AppCommand, server: Server) { +fun executeCommand(command: AppCommand, server: Mcp) { val logger = LoggerFactory.getLogger("Main") when (command.type) { - CommandType.STDIO -> server.runMcpServerUsingStdio() - CommandType.SSE_KTOR -> server.runSseMcpServerUsingKtorPlugin(command.port) - CommandType.SSE_PLAIN -> server.runSseMcpServerWithPlainConfiguration(command.port) + CommandType.STDIO -> server.runUsingStdio() + CommandType.SSE_KTOR -> server.runSseUsingKtorPlugin(command.port) + CommandType.SSE_PLAIN -> server.runSseWithPlainConfiguration(command.port) CommandType.UNKNOWN -> throw IllegalArgumentException("Unknown command: ${command.type}") }.also { logger.info("Executed command: ${command.type}") } } diff --git a/src/main/kotlin/mcp/code/analysis/processor/CodeAnalyzer.kt b/src/main/kotlin/mcp/code/analysis/processor/CodeAnalyzer.kt index c7e22d0..9ea902a 100644 --- a/src/main/kotlin/mcp/code/analysis/processor/CodeAnalyzer.kt +++ b/src/main/kotlin/mcp/code/analysis/processor/CodeAnalyzer.kt @@ -25,14 +25,6 @@ data class CodeAnalyzer( private val logger: Logger = LoggerFactory.getLogger(ModelContextService::class.java), ) { - /** - * Analyzes the structure of a codebase. - * - * @param repoDir The root directory of the repository to analyze - * @return A map representing the directory structure and metadata of files - */ - fun analyzeStructure(repoDir: File): Map = processDirectory(repoDir, repoDir.absolutePath) - /** * Collects summarized code snippets from the repository. * @@ -227,112 +219,6 @@ data class CodeAnalyzer( } } - private fun processDirectory(dir: File, rootPath: String): Map { - // Skip hidden directories and common directories to ignore - val dirsToIgnore = - setOf(".git", "node_modules", "venv", "__pycache__", "target", "build", "dist", ".idea", ".vscode") - - if (dir.isHidden || dir.name in dirsToIgnore) return emptyMap() - val files = dir.listFiles()?.toList() ?: return emptyMap() - val fileEntries = files.filterNot(File::isDirectory).associate { file -> file.name to analyzeFile(file) } - - val directoryEntries = - files - .filter(File::isDirectory) - .flatMap { subDir -> - val dirStructure = processDirectory(subDir, rootPath) - if (dirStructure.isEmpty()) emptyList() - else { - val relativePath = subDir.absolutePath.substring(rootPath.length + 1) - listOf(relativePath to dirStructure) - } - } - .toMap() - - return fileEntries + directoryEntries - } - - private fun analyzeFile(file: File): Map { - // Prepare initial metadata - val fileSize = file.length() - - // Skip large or binary files early - when { - fileSize > binaryFileSizeThreshold -> - return mapOf("size" to fileSize, "extension" to file.extension, "skipped" to "File too large") - - isBinaryFile(file) -> return mapOf("size" to fileSize, "extension" to file.extension, "skipped" to "Binary file") - } - - return try { - val content = file.readText() - val language = getLanguageFromExtension(file.extension) - val imports = extractImports(content, language) - val declarations = extractDeclarations(content, language) - - mapOf( - "size" to fileSize, - "extension" to file.extension, - "lines" to content.lines().size, - "language" to language, - "imports" to imports, - ) + declarations - } catch (e: Exception) { - mapOf("size" to fileSize, "extension" to file.extension, "error" to "Failed to analyze: ${e.message}") - } - } - - private fun isBinaryFile(file: File): Boolean { - // Check if a file is likely a binary by looking at the first few bytes - val binaryExtensions = - setOf( - "class", - "jar", - "war", - "ear", - "zip", - "tar", - "gz", - "rar", - "exe", - "dll", - "so", - "dylib", - "obj", - "o", - "a", - "lib", - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "ico", - "svg", - "pdf", - "doc", - "docx", - "xls", - "xlsx", - "ppt", - "pptx", - ) - - // Quick check based on extension - if (file.extension.lowercase() in binaryExtensions) return true - - // More thorough check by examining content - return try { - val bytes = file.readBytes().take(1000).toByteArray() - val nullCount = bytes.count { it == 0.toByte() } - - // If more than the threshold percentage of the first 1000 bytes is null, likely binary - nullCount > bytes.size * binaryDetectionThreshold - } catch (_: Exception) { - false - } - } - private fun findCodeFiles(dir: File): List = when { dir.isHidden || dir.name == ".git" -> emptyList() @@ -349,217 +235,6 @@ data class CodeAnalyzer( } } - private fun extractImports(content: String, language: String): List = - when (language) { - "kotlin" -> { - val regex = Regex("import\\s+([\\w.]+)(?:\\s+as\\s+\\w+)?.*") - regex.findAll(content).map { it.groupValues[1] }.toList() - } - - "java" -> { - val regex = Regex("import\\s+(?:static\\s+)?([\\w.]+)(?:\\.[*])?;") - regex.findAll(content).map { it.groupValues[1] }.toList() - } - - "python" -> { - val fromImportRegex = Regex("from\\s+([\\w.]+)\\s+import\\s+.+") - val importRegex = Regex("import\\s+([\\w.,\\s]+)") - - val fromImports = fromImportRegex.findAll(content).map { it.groupValues[1] } - val imports = - importRegex.findAll(content).flatMap { result -> - result.groupValues[1].split(",").map { it.trim().split(".").first() } - } - - (fromImports + imports).toList() - } - - "javascript", - "typescript" -> { - val es6ImportRegex = Regex("import\\s+(?:(?:\\{[^}]*\\}|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]") - val requireRegex = Regex("(?:const|let|var)\\s+.+\\s*=\\s*require\\(['\"]([^'\"]+)['\"]\\)") - - val es6Imports = es6ImportRegex.findAll(content).map { it.groupValues[1] } - val requires = requireRegex.findAll(content).map { it.groupValues[1] } - - (es6Imports + requires).toList() - } - - "go" -> { - val singleImportRegex = Regex("import\\s+[\"']([\\w./]+)[\"']") - val multiImportRegex = Regex("import\\s+\\(([^)]+)\\)") - - val singleImports = singleImportRegex.findAll(content).map { it.groupValues[1] } - val multiImports = - multiImportRegex.findAll(content).flatMap { result -> - val importBlock = result.groupValues[1] - Regex("[\"']([\\w./]+)[\"']").findAll(importBlock).map { it.groupValues[1] } - } - - (singleImports + multiImports).toList() - } - - "ruby" -> { - val regex = Regex("require(?:_relative)?\\s+['\"]([^'\"]+)['\"]") - regex.findAll(content).map { it.groupValues[1] }.toList() - } - - "scala" -> { - val regex = Regex("import\\s+([\\w.{}=>,$\\s]+)") - regex.findAll(content).map { it.groupValues[1].trim() }.toList() - } - - "rust" -> { - val regex = Regex("use\\s+([\\w:]+)(?:::\\{[^}]*\\})?;") - regex.findAll(content).map { it.groupValues[1] }.toList() - } - - "php" -> { - val namespaceRegex = Regex("namespace\\s+([\\w\\\\]+);") - val useRegex = Regex("use\\s+([\\w\\\\]+)(?:\\s+as\\s+\\w+)?;") - - val namespace = namespaceRegex.find(content)?.groupValues?.get(1) - val uses = useRegex.findAll(content).map { it.groupValues[1] }.toList() - - if (namespace != null) listOf(namespace) + uses else uses - } - - else -> emptyList() - } - - private fun extractDeclarations(content: String, language: String): Map> { - val declarations = mutableMapOf>() - - when (language) { - "kotlin" -> { - // Classes, interfaces, objects, and data classes - val classRegex = Regex("(?:class|interface|object|enum class|data class)\\s+(\\w+)(?:<[^>]*>)?[^{]*\\{") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions - val functionRegex = - Regex( - "(?:fun|suspend fun)\\s+(?:<[^>]*>\\s+)?(\\w+)\\s*(?:<[^>]*>)?\\s*\\([^)]*\\)(?:\\s*:\\s*[\\w<>?.,\\s]+)?\\s*(?:\\{|=)" - ) - declarations["functions"] = functionRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "java" -> { - // Classes and interfaces - val classRegex = - Regex("(?:public|private|protected|)\\s*(?:class|interface|enum)\\s+(\\w+)(?:<[^>]*>)?[^{]*\\{") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Methods - val methodRegex = - Regex( - "(?:public|private|protected|static|final|native|synchronized|abstract|transient)?\\s*(?:<[^>]*>)?\\s*(?:[\\w<>\\[\\]]+)\\s+(\\w+)\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w,\\s]+)?\\s*\\{" - ) - declarations["methods"] = methodRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "python" -> { - // Classes - val classRegex = Regex("class\\s+(\\w+)(?:\\([^)]*\\))?\\s*:") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions and methods - val functionRegex = Regex("def\\s+(\\w+)\\s*\\([^)]*\\)\\s*(?:->\\s*[^:]+)?\\s*:") - declarations["functions"] = functionRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "go" -> { - // Structs - val structRegex = Regex("type\\s+(\\w+)\\s+struct\\s*\\{") - declarations["structs"] = structRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Interfaces - val interfaceRegex = Regex("type\\s+(\\w+)\\s+interface\\s*\\{") - declarations["interfaces"] = interfaceRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions - val functionRegex = Regex("func\\s+(?:\\([^)]+\\)\\s+)?(\\w+)\\s*\\([^)]*\\)\\s*(?:\\([^)]*\\))?\\s*\\{") - declarations["functions"] = functionRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "javascript", - "typescript" -> { - // Classes - val classRegex = Regex("class\\s+(\\w+)(?:\\s+extends\\s+\\w+)?\\s*\\{") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions (including arrow functions with explicit names) - val functionRegex = - Regex( - "function\\s+(\\w+)|(?:const|let|var)\\s+(\\w+)\\s*=\\s*function|(?:const|let|var)\\s+(\\w+)\\s*=\\s*\\([^)]*\\)\\s*=>" - ) - declarations["functions"] = - functionRegex - .findAll(content) - .map { it.groupValues[1].ifEmpty { it.groupValues[2].ifEmpty { it.groupValues[3] } } } - .filter { it.isNotEmpty() } - .toList() - - // For TypeScript: interfaces and types - if (language == "typescript") { - val interfaceRegex = Regex("interface\\s+(\\w+)(?:<[^>]*>)?\\s*(?:extends\\s+[^{]+)?\\s*\\{") - declarations["interfaces"] = interfaceRegex.findAll(content).map { it.groupValues[1] }.toList() - - val typeRegex = Regex("type\\s+(\\w+)(?:<[^>]*>)?\\s*=") - declarations["types"] = typeRegex.findAll(content).map { it.groupValues[1] }.toList() - } - } - - "scala" -> { - // Classes, objects, and traits - val classRegex = Regex("(?:class|object|trait|case class)\\s+(\\w+)(?:\\[[^\\]]*\\])?[^{]*\\{") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Methods and functions (including vals/vars with function values) - val functionRegex = - Regex("(?:def|val|var)\\s+(\\w+)(?:\\[[^\\]]*\\])?\\s*(?:\\([^)]*\\))?(?:\\s*:\\s*[^=]*)?\\s*=") - declarations["functions"] = functionRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "rust" -> { - // Structs and enums - val structRegex = Regex("(?:pub\\s+)?struct\\s+(\\w+)(?:<[^>]*>)?[^;{]*[{;]") - declarations["structs"] = structRegex.findAll(content).map { it.groupValues[1] }.toList() - - val enumRegex = Regex("(?:pub\\s+)?enum\\s+(\\w+)(?:<[^>]*>)?\\s*\\{") - declarations["enums"] = enumRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions and methods - val functionRegex = - Regex("(?:pub\\s+)?(?:async\\s+)?fn\\s+(\\w+)(?:<[^>]*>)?\\s*\\([^)]*\\)(?:\\s*->\\s*[^{]+)?\\s*\\{") - declarations["functions"] = functionRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Traits (interfaces) - val traitRegex = Regex("(?:pub\\s+)?trait\\s+(\\w+)(?:<[^>]*>)?\\s*(?:\\{|:|where)") - declarations["traits"] = traitRegex.findAll(content).map { it.groupValues[1] }.toList() - } - - "cpp", - "c" -> { - // Classes and structs - val classRegex = Regex("(?:class|struct)\\s+(\\w+)(?::[^{]+)?\\s*\\{") - declarations["classes"] = classRegex.findAll(content).map { it.groupValues[1] }.toList() - - // Functions - val functionRegex = - Regex("(?:[\\w:]+\\s+)+([\\w~]+)\\s*\\([^)]*\\)(?:\\s*const)?(?:\\s*noexcept)?(?:\\s*override)?\\s*(?:\\{|;)") - declarations["functions"] = - functionRegex - .findAll(content) - .map { it.groupValues[1] } - .filter { it !in setOf("if", "for", "while", "switch", "catch") } // Filter out control structures - .toList() - } - } - - return declarations - } - private fun isCodeFile(file: File): Boolean { val codeExtensions = setOf( diff --git a/src/main/kotlin/mcp/code/analysis/server/Server.kt b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt similarity index 96% rename from src/main/kotlin/mcp/code/analysis/server/Server.kt rename to src/main/kotlin/mcp/code/analysis/server/Mcp.kt index b4ef1f8..f913bcc 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Server.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt @@ -32,9 +32,9 @@ import org.slf4j.LoggerFactory * Server for analyzing GitHub repositories using the Model Context Protocol (MCP). Provides functionalities for * analyzing GitHub repositories and checking analysis status. */ -class Server( +class Mcp( private val repositoryAnalysisService: RepositoryAnalysisService = RepositoryAnalysisService(), - private val logger: Logger = LoggerFactory.getLogger(Server::class.java), + private val logger: Logger = LoggerFactory.getLogger(Mcp::class.java), private val implementation: Implementation = Implementation(name = "MCP GitHub Code Analysis Server", version = "0.1.0"), private val serverOptions: ServerOptions = @@ -49,7 +49,7 @@ class Server( ) { /** Starts an MCP server using standard input/output (stdio) for communication. */ - fun runMcpServerUsingStdio() { + fun runUsingStdio() { val server = configureServer() val transport = StdioServerTransport( @@ -71,7 +71,7 @@ class Server( * * @param port The port number on which the SSE MCP server will listen for client connections. */ - fun runSseMcpServerWithPlainConfiguration(port: Int): Unit = runBlocking { + fun runSseWithPlainConfiguration(port: Int): Unit = runBlocking { val servers = ConcurrentMap() logger.info("Starting SSE server on port $port.") logger.info("Use inspector to connect to the http://localhost:$port/sse") @@ -150,7 +150,7 @@ class Server( * * @param port The port number on which the SSE MCP server will listen for client connections. */ - fun runSseMcpServerUsingKtorPlugin(port: Int): Unit = runBlocking { + fun runSseUsingKtorPlugin(port: Int): Unit = runBlocking { logger.debug("Starting SSE server on port $port") logger.debug("Use inspector to connect to http://localhost:$port/sse") diff --git a/src/main/resources/claude_desktop_config.json b/src/main/resources/claude_desktop_config.json index 3fd30e3..9f6fed2 100644 --- a/src/main/resources/claude_desktop_config.json +++ b/src/main/resources/claude_desktop_config.json @@ -4,7 +4,7 @@ "command": "java", "args": [ "-jar", - "/Users/mariano/development/mcp-github-code-analyzer/build/libs/mcp-github-code-analyzer-1.0-SNAPSHOT.jar", + "/Users/mariano/development/mcp-github-code-analyzer/build/libs/mcp-github-code-analyzer-0.1.0-SNAPSHOT.jar", "--stdio" ] } diff --git a/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerPropertyTest.kt b/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerPropertyTest.kt index 6d74059..98c9ae6 100644 --- a/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerPropertyTest.kt +++ b/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerPropertyTest.kt @@ -18,7 +18,7 @@ class CodeAnalyzerPropertyTest : // Generate valid file paths fun getPathGenerator(language: String) = - Arb.Companion.string(1..50).map { base -> + Arb.string(1..50).map { base -> val validPath = base.replace(Regex("[^a-zA-Z0-9/._-]"), "_") val extension = when (language) { diff --git a/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerTest.kt b/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerTest.kt new file mode 100644 index 0000000..e4f7d37 --- /dev/null +++ b/src/test/kotlin/mcp/code/analysis/processor/CodeAnalyzerTest.kt @@ -0,0 +1,154 @@ +package mcp.code.analysis.processor + +import io.mockk.mockk +import java.io.File +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.slf4j.Logger + +class CodeAnalyzerTest { + @TempDir lateinit var tempDir: File + + private lateinit var logger: Logger + private lateinit var analyzer: CodeAnalyzer + + @BeforeEach + fun setUp() { + logger = mockk(relaxed = true) + analyzer = CodeAnalyzer(logger = logger) + } + + data class ReadmeTestCase( + val name: String, + val files: Map, + val expectedContent: String, + val containsLogMessage: String, + ) + + data class SnippetsTestCase( + val name: String, + val files: Map, + val expectedSnippetCount: Int, + val shouldContainFiles: List, + ) + + @Test + fun `test findReadmeFile with various scenarios`() { + val testCases = + listOf( + ReadmeTestCase( + name = "standard README.md", + files = + mapOf( + "README.md" to + """|# Project + |This is a test project""" + .trimMargin() + ), + expectedContent = + """|# Project + |This is a test project""" + .trimMargin(), + containsLogMessage = "Readme file found", + ), + ReadmeTestCase( + name = "alternate case readme.md", + files = mapOf("readme.md" to "# Lowercase README"), + expectedContent = "# Lowercase README", + containsLogMessage = "Readme file found", + ), + ReadmeTestCase( + name = "README.txt format", + files = mapOf("README.txt" to "Plain text readme"), + expectedContent = "Plain text readme", + containsLogMessage = "Readme file found", + ), + ReadmeTestCase( + name = "no readme file", + files = mapOf("other.txt" to "Not a readme"), + expectedContent = "No README content available.", + containsLogMessage = "No readme file found", + ), + ReadmeTestCase( + name = "priority check (README.md over readme.txt)", + files = mapOf("README.md" to "# Markdown Readme", "readme.txt" to "Text Readme"), + expectedContent = "# Markdown Readme", + containsLogMessage = "Readme file found", + ), + ) + + testCases.forEach { testCase -> + // Clear the directory and create test files + tempDir.listFiles()?.forEach { it.delete() } + + testCase.files.forEach { (filename, content) -> File(tempDir, filename).writeText(content) } + + // Execute test + val result = analyzer.findReadmeFile(tempDir) + + // Verify results + Assertions.assertEquals(testCase.expectedContent, result, """Test case "${testCase.name}" failed""") + } + } + + @Test + fun `test collectAllCodeSnippets with different file types`() { + val testCases = + listOf( + SnippetsTestCase( + name = "mixed code files", + files = + mapOf( + "main.kt" to "fun main() {}", + "helper.java" to "class Helper {}", + "script.py" to "def hello(): pass", + "README.md" to "# Not code", + ), + expectedSnippetCount = 3, + shouldContainFiles = listOf("main.kt", "helper.java", "script.py"), + ), + SnippetsTestCase( + name = "exclude test files", + files = mapOf("src/main.kt" to "fun main() {}", "test/TestMain.kt" to "fun testMain() {}"), + expectedSnippetCount = 1, + shouldContainFiles = listOf("src/main.kt"), + ), + SnippetsTestCase( + name = "empty directory", + files = emptyMap(), + expectedSnippetCount = 0, + shouldContainFiles = emptyList(), + ), + ) + + testCases.forEach { testCase -> + // Arrange + tempDir.listFiles()?.forEach { it.deleteRecursively() } + + testCase.files.forEach { (path, content) -> + val file = File(tempDir, path) + file.parentFile.mkdirs() + file.writeText(content) + } + + // Act + val snippets = analyzer.collectSummarizedCodeSnippets(tempDir) + + // Assert + Assertions.assertEquals( + testCase.expectedSnippetCount, + snippets.size, + """Test case "${testCase.name}" should have ${testCase.expectedSnippetCount} snippets""", + ) + + testCase.shouldContainFiles.forEach { filename -> + Assertions.assertTrue( + snippets.any { it.contains(filename) }, + """Snippets should contain $filename in test "${testCase.name}"""", + ) + } + } + } +} diff --git a/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt similarity index 97% rename from src/test/kotlin/mcp/code/analysis/server/ServerTest.kt rename to src/test/kotlin/mcp/code/analysis/server/McpTest.kt index a741e90..c1b09d9 100644 --- a/src/test/kotlin/mcp/code/analysis/server/ServerTest.kt +++ b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt @@ -14,16 +14,16 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -class ServerTest { +class McpTest { private lateinit var repositoryAnalysisService: RepositoryAnalysisService - private lateinit var serverUnderTest: Server + private lateinit var serverUnderTest: Mcp private val toolHandlerSlot = slot CallToolResult>() @BeforeEach fun setUp() { repositoryAnalysisService = mockk() - serverUnderTest = Server(repositoryAnalysisService = repositoryAnalysisService) + serverUnderTest = Mcp(repositoryAnalysisService = repositoryAnalysisService) mockkConstructor(SdkServer::class) diff --git a/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt b/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt deleted file mode 100644 index 99f80fc..0000000 --- a/src/test/kotlin/mcp/code/analysis/service/CodeAnalyzerTest.kt +++ /dev/null @@ -1,681 +0,0 @@ -package mcp.code.analysis.service - -import io.mockk.mockk -import java.io.File -import kotlin.collections.get -import mcp.code.analysis.processor.CodeAnalyzer -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import org.slf4j.Logger - -class CodeAnalyzerTest { - @TempDir lateinit var tempDir: File - - private lateinit var logger: Logger - private lateinit var analyzer: CodeAnalyzer - - @BeforeEach - fun setUp() { - logger = mockk(relaxed = true) - analyzer = CodeAnalyzer(logger = logger) - } - - data class ReadmeTestCase( - val name: String, - val files: Map, - val expectedContent: String, - val containsLogMessage: String, - ) - - data class SnippetsTestCase( - val name: String, - val files: Map, - val expectedSnippetCount: Int, - val shouldContainFiles: List, - ) - - @Test - fun `test findReadmeFile with various scenarios`() { - val testCases = - listOf( - ReadmeTestCase( - name = "standard README.md", - files = - mapOf( - "README.md" to - """|# Project - |This is a test project""" - .trimMargin() - ), - expectedContent = - """|# Project - |This is a test project""" - .trimMargin(), - containsLogMessage = "Readme file found", - ), - ReadmeTestCase( - name = "alternate case readme.md", - files = mapOf("readme.md" to "# Lowercase README"), - expectedContent = "# Lowercase README", - containsLogMessage = "Readme file found", - ), - ReadmeTestCase( - name = "README.txt format", - files = mapOf("README.txt" to "Plain text readme"), - expectedContent = "Plain text readme", - containsLogMessage = "Readme file found", - ), - ReadmeTestCase( - name = "no readme file", - files = mapOf("other.txt" to "Not a readme"), - expectedContent = "No README content available.", - containsLogMessage = "No readme file found", - ), - ReadmeTestCase( - name = "priority check (README.md over readme.txt)", - files = mapOf("README.md" to "# Markdown Readme", "readme.txt" to "Text Readme"), - expectedContent = "# Markdown Readme", - containsLogMessage = "Readme file found", - ), - ) - - testCases.forEach { testCase -> - // Clear the directory and create test files - tempDir.listFiles()?.forEach { it.delete() } - - testCase.files.forEach { (filename, content) -> File(tempDir, filename).writeText(content) } - - // Execute test - val result = analyzer.findReadmeFile(tempDir) - - // Verify results - assertEquals(testCase.expectedContent, result, """Test case "${testCase.name}" failed""") - } - } - - @Test - fun `test analyzeStructure with realistic directory structures`() { - // Arrange - val subDir = File(tempDir, "src").apply { mkdir() } - - // Java file with imports, package, class and methods - File(tempDir, "main.java").apply { - parentFile.mkdirs() - writeText( - """| - |package com.example; - | - |import java.util.List; - |import java.util.ArrayList; - |import java.io.IOException; - | - |/** - | * Helper class that provides utility methods - | */ - |public class Helper { - | private final List items; - | - | public Helper() { - | this.items = new ArrayList<>(); - | } - | - | /** - | * Add an item to the collection - | */ - | public void addItem(String item) throws IOException { - | if (item == null) { - | throw new IllegalArgumentException("Item cannot be null"); - | } - | items.add(item); - | } - | - | public List getItems() { - | return new ArrayList<>(items); - | } - |} - |""" - .trimIndent() - ) - } - - // Kotlin file with imports, classes and functions - File(tempDir, "main.kt").apply { - parentFile.mkdirs() - writeText( - """| - | - |package com.example - | - |import java.time.LocalDateTime - |import kotlin.math.max - | - |/** - | * Main application entry point - | */ - |fun main() { - | val app = Application() - | app.run() - |} - | - |// Configuration data class - |data class Config( - | val name: String, - | val version: String, - | val timestamp: LocalDateTime = LocalDateTime.now() - |) - | - |class Application { - | private val config = Config( - | name = "Demo App", - | version = "1.0.0" - | ) - | - | fun run() { - | println("Startin"") - | processItems(listOf("apple", "orange", "banana")) - | } - | - | private fun processItems(items: List) { - | val largest = items.maxByOrNull { it.length } - | println("Longest item: 10 with 1000 characters") - | } - |} - |""" - .trimIndent() - ) - } - - // Python file with imports, classes, functions, docstrings - File(tempDir, "main.py").apply { - parentFile.mkdirs() - writeText( - """| - |import os - |import sys - |from typing import List, Optional - |from dataclasses import dataclass - | - |@dataclass - |class User: - | \"\"\"User data model class\"\"\" - | name: str - | email: str - | active: bool = True - | - |class UserRepository: - | \"\"\"Handles user data storage and retrieval\"\"\" - | - | def __init__(self): - | self.users = {} - | - | def add_user(self, user: User) -> None: - | \"\"\"Add a new user to the repository\"\"\" - | if user.email in self.users: - | raise ValueError(f"User with email {user.email} already exists") - | self.users[user.email] = user - | - | def find_by_email(self, email: str) -> Optional[User]: - | \"\"\"Retrieve a user by their email\"\"\" - | return self.users.get(email) - | - |def main(): - | \"\"\"Main application entry point\"\"\" - | repo = UserRepository() - | repo.add_user(User(name="John Doe", email="john@example.com")) - | repo.add_user(User(name="Jane Smith", email="jane@example.com")) - | - | print(f"Found: {repo.find_by_email('john@example.com')}") - | - |if __name__ == "__main__": - | main() - |""" - .trimIndent() - ) - } - - // Go file with packages, imports, structs, interfaces, functions - File(tempDir, "main.go").apply { - parentFile.mkdirs() - writeText( - """| - |package server - | - |import ( - | "fmt" - | "log" - | "net/http" - | "time" - |) - | - |// Config holds server configuration - |type Config struct { - | Port int - | Timeout time.Duration - |} - | - |// Handler defines the interface for request handlers - |type Handler interface { - | ServeHTTP(w http.ResponseWriter, r *http.Request) - |} - | - |// Server represents an HTTP server instance - |type Server struct { - | config Config - | handler Handler - | started bool - |} - | - |// NewServer creates a new server instance - |func NewServer(config Config, handler Handler) *Server { - | return &Server{ - | config: config, - | handler: handler, - | } - |} - | - |// Start begins listening on the configured port - |func (s *Server) Start() error { - | if s.started { - | return fmt.Errorf("server already started") - | } - | - | addr := fmt.Sprintf(":%d", s.config.Port) - | log.Printf("Starting server on %s", addr) - | - | s.started = true - | return http.ListenAndServe(addr, s.handler) - |} - |""" - .trimIndent() - ) - } - - // TypeScript file with imports, interfaces, classes - File(tempDir, "main.ts").apply { - parentFile.mkdirs() - writeText( - """| - |import { AxiosInstance, AxiosRequestConfig } from 'axios'; - |import { Observable, from } from 'rxjs'; - |import { map, catchError } from 'rxjs/operators'; - | - |/** - | * API response interface - | */ - |export interface ApiResponse { - | data: T; - | status: number; - | message: string; - |} - | - |/** - | * Configuration options for the API client - | */ - |export interface ApiClientOptions { - | baseUrl: string; - | timeout?: number; - | headers?: Record; - |} - | - |/** - | * Client for making API requests - | */ - |export class ApiClient { - | private axios: AxiosInstance; - | private options: ApiClientOptions; - | - | constructor(options: ApiClientOptions) { - | this.options = { - | timeout: 30000, - | ...options - | }; - | - | // Initialize axios instance - | this.axios = axios.create({ - | baseURL: this.options.baseUrl, - | timeout: this.options.timeout, - | headers: this.options.headers - | }); - | } - | - | /** - | * Makes a GET request to the specified endpoint - | */ - | public get(url: string, config?: AxiosRequestConfig): Observable> { - | return from(this.axios.get>(url, config)).pipe( - | map(response => response.data), - | catchError(error => { - | console.error(API error: error); - | throw error; - | }) - | ); - | } - |} - |""" - .trimIndent() - ) - } - - // JavaScript file with modules, classes, functions - File(tempDir, "main.js").apply { - parentFile.mkdirs() - writeText( - """| - |/** - | * @fileoverview Utility functions for the application - | */ - | - |const crypto = require('crypto'); - |const fs = require('fs').promises; - |const path = require('path'); - | - |/** - | * Generates a secure random token - | * @param {number} length The desired length of the token - | * @returns {string} A random token string - | */ - |function generateToken(length = 32) { - | return crypto.randomBytes(length).toString('hex'); - |} - | - |/** - | * File cache implementation - | */ - |class FileCache { - | /** - | * @param {number} maxItems Maximum number of items to store in cache - | */ - | constructor(maxItems = 100) { - | this.cache = new Map(); - | this.maxItems = maxItems; - | } - | - | /** - | * Store a file in cache - | * @param {string} filePath Path to the file - | * @param {*} contents File contents - | */ - | set(filePath, contents) { - | // Evict oldest entry if at capacity - | if (this.cache.size >= this.maxItems) { - | const oldestKey = this.cache.keys().next().value; - | this.cache.delete(oldestKey); - | } - | - | this.cache.set(filePath, { - | contents, - | timestamp: Date.now() - | }); - | } - | - | /** - | * Get a file from cache - | * @param {string} filePath Path to the file - | * @returns {*} File contents or null if not in cache - | */ - | get(filePath) { - | const entry = this.cache.get(filePath); - | return entry ? entry.contents : null; - | } - |} - | - |module.exports = { - | generateToken, - | FileCache - |}; - """ - .trimIndent() - ) - } - - // Ruby file with requires, modules, classes - File(tempDir, "main.rb").apply { - parentFile.mkdirs() - writeText( - """| - |require 'json' - |require 'date' - |require 'logger' - | - |# Configuration module for application settings - |module Config - | extend self - | - | # Load configuration from environment or defaults - | def load - | { - | environment: ENV['APP_ENV'] || 'development', - | log_level: ENV['LOG_LEVEL'] || 'info', - | max_threads: (ENV['MAX_THREADS'] || '5').to_i - | } - | end - |end - | - |# Application logger - |class AppLogger - | attr_reader :logger - | - | def initialize(level = :info) - | @logger = Logger.new(STDOUT) - | @logger.level = Logger.const_get(level.to_s.upcase) - | end - | - | def info(message) - | @logger.info("[#{timestamp}] #{message}") - | end - | - | def error(message) - | @logger.error("[#{timestamp}] ERROR: #{message}") - | end - | - | private - | - | def timestamp - | DateTime.now.strftime('%Y-%m-%d %H:%M:%S') - | end - |end - | - |# Application entry point - |class Application - | def initialize - | @config = Config.load - | @logger = AppLogger.new(@config[:log_level]) - | end - | - | def run - | @logger.info("Starting application in #{@config[:environment]} mode") - | @logger.info("Using #{@config[:max_threads]} threads") - | end - |end - | - |# Run the application if this file is executed directly - |if __FILE__ == PROGRAM_NAME - | app = Application.new - | app.run - |end - |""" - .trimIndent() - ) - } - - // Scala file with traits, classes, objects - File(tempDir, "main.scala").apply { - parentFile.mkdirs() - writeText( - """| - |package com.example - | - |import java.time.LocalDateTime - |import scala.util.{Try, Success, Failure} - | - |/** - | * Entity base trait for domain models - | */ - |trait Entity { - | def id: String - | def createdAt: LocalDateTime - |} - | - |/** - | * User entity implementation - | */ - |case class User( - | id: String, - | email: String, - | displayName: String, - | createdAt: LocalDateTime = LocalDateTime.now(), - | active: Boolean = true - |) extends Entity - | - |/** - | * Repository interface for data access - | */ - |trait Repository[T <: Entity] { - | def findById(id: String): Option[T] - | def save(entity: T): Try[T] - | def delete(id: String): Try[Unit] - |} - | - |/** - | * In-memory implementation of the user repository - | */ - |class InMemoryUserRepository extends Repository[User] { - | private var users = Map.empty[String, User] - | - | override def findById(id: String): Option[User] = users.get(id) - | - | override def save(user: User): Try[User] = { - | users = users + (user.id -> user) - | Success(user) - | } - | - | override def delete(id: String): Try[Unit] = { - | if (users.contains(id)) { - | users = users - id - | Success(()) - | } else { - | Failure(new NoSuchElementException(s"User with id 1 not found")) - | } - | } - |} - | - |/** - | * Companion object with factory methods - | */ - |object User { - | def create(email: String, displayName: String): User = { - | val id = java.util.UUID.randomUUID().toString - | User(id, email, displayName) - | } - |}""" - .trimIndent() - ) - } - - // Act - val result = analyzer.analyzeStructure(tempDir) - - // Assert - assertTrue(result.containsKey("main.java"), "Should contain main.kt file") - assertTrue(result.containsKey("main.kt"), "Should contain main.kt file") - assertTrue(result.containsKey("main.scala"), "Should contain main.scala file") - assertTrue(result.containsKey("main.py"), "Should contain main.py file") - assertTrue(result.containsKey("main.go"), "Should contain main.go file") - assertTrue(result.containsKey("main.ts"), "Should contain main.ts file") - assertTrue(result.containsKey("main.rb"), "Should contain main.rb file") - assertTrue(result.containsKey("main.js"), "Should contain main.js file") - - val mainJavaInfo = result["main.java"] as? Map<*, *> - assertNotNull(mainJavaInfo, "main.java should have metadata") - assertEquals("java", mainJavaInfo!!["language"], "Should identify Java language") - - val mainKtInfo = result["main.kt"] as? Map<*, *> - assertNotNull(mainKtInfo, "main.kt should have metadata") - assertEquals("kotlin", mainKtInfo!!["language"], "Should identify Kotlin language") - - val mainScalaInfo = result["main.scala"] as? Map<*, *> - assertNotNull(mainScalaInfo, "main.scala should have metadata") - assertEquals("scala", mainScalaInfo!!["language"], "Should identify Scala language") - - val mainPyInfo = result["main.py"] as? Map<*, *> - assertNotNull(mainPyInfo, "main.py should have metadata") - assertEquals("python", mainPyInfo!!["language"], "Should identify Python language") - - val mainGoInfo = result["main.go"] as? Map<*, *> - assertNotNull(mainGoInfo, "main.go should have metadata") - assertEquals("go", mainGoInfo!!["language"], "Should identify Go language") - - val mainTsInfo = result["main.ts"] as? Map<*, *> - assertNotNull(mainTsInfo, "main.ts should have metadata") - assertEquals("typescript", mainTsInfo!!["language"], "Should identify TypeScript language") - - val mainJsInfo = result["main.js"] as? Map<*, *> - assertNotNull(mainJsInfo, "main.js should have metadata") - assertEquals("javascript", mainJsInfo!!["language"], "Should identify JavaScript language") - - val mainRbInfo = result["main.rb"] as? Map<*, *> - assertNotNull(mainRbInfo, "main.rb should have metadata") - assertEquals("ruby", mainRbInfo!!["language"], "Should identify Ruby language") - } - - @Test - fun `test collectAllCodeSnippets with different file types`() { - val testCases = - listOf( - SnippetsTestCase( - name = "mixed code files", - files = - mapOf( - "main.kt" to "fun main() {}", - "helper.java" to "class Helper {}", - "script.py" to "def hello(): pass", - "README.md" to "# Not code", - ), - expectedSnippetCount = 3, - shouldContainFiles = listOf("main.kt", "helper.java", "script.py"), - ), - SnippetsTestCase( - name = "exclude test files", - files = mapOf("src/main.kt" to "fun main() {}", "test/TestMain.kt" to "fun testMain() {}"), - expectedSnippetCount = 1, - shouldContainFiles = listOf("src/main.kt"), - ), - SnippetsTestCase( - name = "empty directory", - files = emptyMap(), - expectedSnippetCount = 0, - shouldContainFiles = emptyList(), - ), - ) - - testCases.forEach { testCase -> - // Arrange - tempDir.listFiles()?.forEach { it.deleteRecursively() } - - testCase.files.forEach { (path, content) -> - val file = File(tempDir, path) - file.parentFile.mkdirs() - file.writeText(content) - } - - // Act - val snippets = analyzer.collectSummarizedCodeSnippets(tempDir) - - // Assert - assertEquals( - testCase.expectedSnippetCount, - snippets.size, - """Test case "${testCase.name}" should have ${testCase.expectedSnippetCount} snippets""", - ) - - testCase.shouldContainFiles.forEach { filename -> - assertTrue( - snippets.any { it.contains(filename) }, - """Snippets should contain $filename in test "${testCase.name}"""", - ) - } - } - } -}