c118996746
- 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
381 lines
17 KiB
Swift
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)"]]
|
|
}
|
|
}
|