Files
CxIDE/Agent/Tools/DiagnosticsTools.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge)
- Agentic AI loop: LLM plans → tool calls → execution → feedback loop
- 15 agent tools: file ops, terminal, git, xcode build, code intel
- 7 persistent terminal tools with background session management
- Chat sidebar with agent step rendering and auto-apply
- NVIDIA NIM API integration (Llama 3.3 70B default)
- OpenAI tool_calls format with prompt-based fallback
- Code editor with syntax highlighting and multi-tab support
- File tree, console view, terminal view
- Git integration and workspace management
2026-04-21 16:05:52 -05:00

381 lines
17 KiB
Swift

// 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)"]]
}
}