// DiagnosticsTools.swift // CxSwiftAgent — Diagnostics & Analysis MCP Tools // // 5 tools: diag_workspace, diag_lint, diag_todo, diag_duplicates, diag_complexity import Foundation enum DiagnosticsTools { static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) { // ── diag_workspace ──────────────────────────────────────────── server.registerTool( "diag_workspace", description: "Workspace health diagnostic: file counts, sizes, project type, structure issues.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "Root directory (default: workspace root)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let fm = FileManager.default var totalFiles = 0 var totalDirs = 0 var totalSize: Int64 = 0 var extensions: [String: Int] = [:] var largestFiles: [(String, Int64)] = [] guard let enumerator = fm.enumerator(atPath: root) else { return err("Cannot enumerate workspace") } while let file = enumerator.nextObject() as? String { if config.isExcluded(file) { enumerator.skipDescendants(); continue } let fullPath = (root as NSString).appendingPathComponent(file) var isDir: ObjCBool = false fm.fileExists(atPath: fullPath, isDirectory: &isDir) if isDir.boolValue { totalDirs += 1 } else { totalFiles += 1 if let attrs = try? fm.attributesOfItem(atPath: fullPath), let size = attrs[.size] as? Int64 { totalSize += size largestFiles.append((file, size)) } let ext = (file as NSString).pathExtension if !ext.isEmpty { extensions[ext, default: 0] += 1 } } } largestFiles.sort { $0.1 > $1.1 } let top5 = largestFiles.prefix(5) var output = "Workspace Diagnostics:\n" output += " Root: \(root)\n" output += " Files: \(totalFiles), Directories: \(totalDirs)\n" output += " Total size: \(formatSize(totalSize))\n\n" output += " Top extensions:\n" for (ext, count) in extensions.sorted(by: { $0.value > $1.value }).prefix(10) { output += " .\(ext): \(count) files\n" } output += "\n Largest files:\n" for (file, size) in top5 { output += " \(file) (\(formatSize(size)))\n" } return ok(output) } // ── diag_lint ───────────────────────────────────────────────── server.registerTool( "diag_lint", description: "Run linting on source files. Detects common style issues.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "File or directory to lint."], "language": ["type": "string", "description": "Language: swift, python, javascript (auto-detect if omitted)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let target = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let lang = args["language"] as? String if lang == "swift" || target.hasSuffix(".swift") { return runCmd("swiftlint", ["lint", "--path", target]) } else if lang == "python" || target.hasSuffix(".py") { return runCmd("python3", ["-m", "flake8", target]) } else if lang == "javascript" || target.hasSuffix(".js") || target.hasSuffix(".ts") { return runCmd("npx", ["eslint", target]) } // Basic file-level checks return lintFiles(at: target, config: config) } // ── diag_todo ───────────────────────────────────────────────── server.registerTool( "diag_todo", description: "Find TODO, FIXME, HACK, BUG, XXX comments in source files.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "Directory to search (default: workspace root)."], "tags": ["type": "string", "description": "Comma-separated tags (default: TODO,FIXME,HACK,BUG,XXX)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let tagsStr = args["tags"] as? String ?? "TODO,FIXME,HACK,BUG,XXX" let tags = tagsStr.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces).uppercased() } let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: root) else { return err("Cannot enumerate directory") } let codeExts = Set(["swift", "py", "js", "ts", "php", "pl", "pm", "rb", "go", "rs", "c", "cpp", "h", "m"]) var results: [String] = [] while let file = enumerator.nextObject() as? String { if config.isExcluded(file) { enumerator.skipDescendants(); continue } let ext = (file as NSString).pathExtension guard codeExts.contains(ext) else { continue } let fullPath = (root as NSString).appendingPathComponent(file) guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue } let lines = content.components(separatedBy: "\n") for (i, line) in lines.enumerated() { let upper = line.uppercased() for tag in tags { if upper.contains(tag) { results.append(" \(file):\(i + 1): \(line.trimmingCharacters(in: .whitespaces))") break } } if results.count >= 100 { break } } if results.count >= 100 { break } } return ok(results.isEmpty ? "No TODO/FIXME comments found" : "Found \(results.count) items:\n" + results.joined(separator: "\n")) } // ── diag_duplicates ─────────────────────────────────────────── server.registerTool( "diag_duplicates", description: "Find potential duplicate code blocks across files.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "Directory to scan (default: workspace root)."], "min_lines": ["type": "integer", "description": "Minimum duplicate block size in lines (default: 5)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let minLines = args["min_lines"] as? Int ?? 5 let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: root) else { return err("Cannot enumerate directory") } let codeExts = Set(["swift", "py", "js", "ts", "php", "pl", "pm"]) var blockHashes: [String: [(String, Int)]] = [:] // hash -> [(file, startLine)] while let file = enumerator.nextObject() as? String { if config.isExcluded(file) { enumerator.skipDescendants(); continue } let ext = (file as NSString).pathExtension guard codeExts.contains(ext) else { continue } let fullPath = (root as NSString).appendingPathComponent(file) guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue } let lines = content.components(separatedBy: "\n") guard lines.count >= minLines else { continue } for i in 0...(lines.count - minLines) { let block = lines[i..<(i + minLines)] .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } guard block.count >= minLines - 1 else { continue } let hash = block.joined(separator: "\n") guard hash.count > 20 else { continue } // Skip trivial blocks blockHashes[hash, default: []].append((file, i + 1)) } } let duplicates = blockHashes.filter { $0.value.count > 1 } if duplicates.isEmpty { return ok("No duplicate code blocks found (min \(minLines) lines)") } var output = "Found \(duplicates.count) potential duplicate blocks:\n" for (_, locations) in duplicates.prefix(10) { output += "\n Duplicate (\(locations.count) occurrences):\n" for (file, line) in locations { output += " \(file):\(line)\n" } } return ok(output) } // ── diag_complexity ─────────────────────────────────────────── server.registerTool( "diag_complexity", description: "Analyze code complexity: function lengths, nesting depth, parameter counts.", inputSchema: [ "type": "object", "required": ["path"], "properties": [ "path": ["type": "string", "description": "Source file to analyze."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed") } guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return err("Cannot read file") } let lines = content.components(separatedBy: "\n") var output = "Complexity Analysis: \(args["path"] as? String ?? path)\n" output += " Total lines: \(lines.count)\n" // Count functions and their lengths var functions: [(String, Int, Int)] = [] // (name, startLine, length) var maxNesting = 0 var currentNesting = 0 var inFunction = false var funcStart = 0 var funcName = "" for (i, line) in lines.enumerated() { let trimmed = line.trimmingCharacters(in: .whitespaces) // Track nesting currentNesting += trimmed.filter { $0 == "{" }.count currentNesting -= trimmed.filter { $0 == "}" }.count maxNesting = max(maxNesting, currentNesting) // Detect functions if trimmed.contains("func ") { let name = trimmed.components(separatedBy: "func ").last? .components(separatedBy: "(").first? .trimmingCharacters(in: .whitespaces) ?? "?" if inFunction { functions.append((funcName, funcStart, i - funcStart)) } inFunction = true funcStart = i + 1 funcName = name } } if inFunction { functions.append((funcName, funcStart, lines.count - funcStart)) } // Sort by length (longest first) functions.sort { $0.2 > $1.2 } output += " Functions: \(functions.count)\n" output += " Max nesting depth: \(maxNesting)\n" if !functions.isEmpty { output += "\n Longest functions:\n" for (name, line, length) in functions.prefix(10) { let flag = length > 50 ? " ⚠️" : "" output += " \(name) (L\(line), \(length) lines)\(flag)\n" } let avgLength = functions.map(\.2).reduce(0, +) / max(functions.count, 1) output += "\n Average function length: \(avgLength) lines\n" } return ok(output) } } // MARK: - Helpers private static func lintFiles(at path: String, config: AgentConfig) -> [[String: Any]] { let fm = FileManager.default var issues: [String] = [] let checkFile = { (file: String, fullPath: String) in guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { return } let lines = content.components(separatedBy: "\n") for (i, line) in lines.enumerated() { // Trailing whitespace if line != line.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression) { issues.append("\(file):\(i + 1): trailing whitespace") } // Line too long if line.count > 120 { issues.append("\(file):\(i + 1): line too long (\(line.count) > 120)") } } // No newline at end of file if !content.hasSuffix("\n") { issues.append("\(file): no newline at end of file") } } var isDir: ObjCBool = false if fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue { if let enumerator = fm.enumerator(atPath: path) { while let file = enumerator.nextObject() as? String { if config.isExcluded(file) { enumerator.skipDescendants(); continue } let ext = (file as NSString).pathExtension guard ["swift", "py", "js", "ts", "php", "pl", "pm"].contains(ext) else { continue } let fullPath = (path as NSString).appendingPathComponent(file) checkFile(file, fullPath) if issues.count >= 100 { break } } } } else { checkFile((path as NSString).lastPathComponent, path) } return ok(issues.isEmpty ? "No lint issues found" : "Found \(issues.count) issues:\n" + issues.joined(separator: "\n")) } private static func runCmd(_ executable: String, _ args: [String]) -> [[String: Any]] { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/\(executable)") process.arguments = args let pipe = Pipe() let errPipe = Pipe() process.standardOutput = pipe process.standardError = errPipe do { try process.run() process.waitUntilExit() let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let errOutput = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" return ok(output + (errOutput.isEmpty ? "" : "\n\(errOutput)")) } catch { return err("\(executable) not available: \(error.localizedDescription)") } } private static func formatSize(_ bytes: Int64) -> String { if bytes < 1024 { return "\(bytes) B" } if bytes < 1_048_576 { return "\(bytes / 1024) KB" } return String(format: "%.1f MB", Double(bytes) / 1_048_576) } private static func ok(_ text: String) -> [[String: Any]] { [["type": "text", "text": text]] } private static func err(_ message: String) -> [[String: Any]] { [["type": "text", "text": "Error: \(message)"]] } }