From 6b9c86a399b87f44e902f24b8578dfa5a4844b10 Mon Sep 17 00:00:00 2001 From: Feitong Yang Date: Mon, 2 Mar 2026 10:17:59 -0800 Subject: [PATCH] Add native macOS desktop app (Swift/SPM) --- desktop/macos-claude/Package.resolved | 15 + desktop/macos-claude/Package.swift | 101 ++++ .../LionsAnalysis/AnalyzerProtocol.swift | 11 + .../Sources/LionsAnalysis/CallGraph.swift | 68 +++ .../Sources/LionsAnalysis/ImportGraph.swift | 140 ++++++ .../Sources/LionsAnalysis/PageRank.swift | 45 ++ .../Sources/LionsAnalysis/ReadingOrder.swift | 71 +++ .../Sources/LionsAnalysis/RepoAnalyzer.swift | 104 +++++ .../Sources/LionsAnalysis/SymbolTable.swift | 14 + .../Sources/LionsApp/AppState.swift | 114 +++++ .../Sources/LionsApp/LionsDesktopApp.swift | 73 +++ .../Sources/LionsApp/ProjectState.swift | 136 ++++++ .../LionsApp/Utilities/AppKitTextField.swift | 80 ++++ .../LionsApp/Utilities/DialogPresenter.swift | 53 +++ .../LionsApp/Utilities/KeychainHelper.swift | 52 +++ .../Sources/LionsApp/Views/CodeView.swift | 118 +++++ .../Sources/LionsApp/Views/LibraryView.swift | 48 ++ .../LionsApp/Views/NewProjectSheet.swift | 75 +++ .../LionsApp/Views/PipelineProgressView.swift | 53 +++ .../Sources/LionsApp/Views/SettingsView.swift | 65 +++ .../Sources/LionsApp/Views/SidebarView.swift | 103 +++++ .../Sources/LionsLLM/AnthropicClient.swift | 127 +++++ .../Sources/LionsLLM/JSONRepair.swift | 62 +++ .../Sources/LionsLLM/LLMClientProtocol.swift | 42 ++ .../Sources/LionsLLM/NoOpLLMClient.swift | 25 + .../Sources/LionsLLM/SSEParser.swift | 94 ++++ .../Sources/LionsModels/Annotations.swift | 138 ++++++ .../Sources/LionsModels/AnyCodable.swift | 72 +++ .../Sources/LionsModels/Atoms.swift | 113 +++++ .../Sources/LionsModels/DesignTokens.swift | 172 +++++++ .../Sources/LionsModels/Diff.swift | 90 ++++ .../Sources/LionsModels/Guide.swift | 67 +++ .../Sources/LionsModels/LLMUsage.swift | 40 ++ .../Sources/LionsModels/PipelineEvent.swift | 13 + .../Sources/LionsModels/RepoAnalysis.swift | 116 +++++ .../Sources/LionsModels/RepoSummary.swift | 86 ++++ .../LionsPipeline/AnnotationBuilder.swift | 149 ++++++ .../LionsPipeline/CrossFileContext.swift | 51 ++ .../Sources/LionsPipeline/GuideBuilder.swift | 85 ++++ .../LionsPipeline/PipelineOrchestrator.swift | 330 +++++++++++++ .../LionsPipeline/PipelineProtocol.swift | 16 + .../LionsPipeline/PromptTemplates.swift | 113 +++++ .../LionsPipeline/SummaryBuilder.swift | 258 +++++++++++ .../Sources/LionsPipeline/TokenBudget.swift | 15 + .../LionsSources/GitDiffProvider.swift | 191 ++++++++ .../Sources/LionsSources/GitHubProvider.swift | 115 +++++ .../LionsSources/LanguageDetection.swift | 24 + .../LionsSources/LocalFileProvider.swift | 68 +++ .../Sources/LionsSources/SourceProvider.swift | 8 + .../Sources/LionsSources/URLParsing.swift | 46 ++ .../Sources/LionsStorage/GRDBStorage.swift | 313 +++++++++++++ .../Sources/LionsStorage/Migrations.swift | 53 +++ .../Sources/LionsStorage/StorageError.swift | 15 + .../LionsStorage/StorageProtocol.swift | 130 ++++++ .../LionsAnalysisTests/AnalysisTests.swift | 218 +++++++++ .../Tests/LionsLLMTests/LLMTests.swift | 185 ++++++++ .../Tests/LionsModelsTests/ModelsTests.swift | 437 ++++++++++++++++++ .../LionsPipelineTests/PipelineTests.swift | 247 ++++++++++ .../LionsSourcesTests/SourcesTests.swift | 166 +++++++ .../LionsStorageTests/GRDBStorageTests.swift | 210 +++++++++ .../Tests/UITests/SheetTextFieldTest.swift | 212 +++++++++ .../Tests/UITests/VerifyTextField.swift | 181 ++++++++ .../Tests/UITests/test_keyboard.sh | 219 +++++++++ 63 files changed, 6851 insertions(+) create mode 100644 desktop/macos-claude/Package.resolved create mode 100644 desktop/macos-claude/Package.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/AnalyzerProtocol.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/CallGraph.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/ImportGraph.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/PageRank.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/ReadingOrder.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/RepoAnalyzer.swift create mode 100644 desktop/macos-claude/Sources/LionsAnalysis/SymbolTable.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/AppState.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/LionsDesktopApp.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/ProjectState.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Utilities/AppKitTextField.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Utilities/DialogPresenter.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Utilities/KeychainHelper.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/CodeView.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/LibraryView.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/NewProjectSheet.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/PipelineProgressView.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/SettingsView.swift create mode 100644 desktop/macos-claude/Sources/LionsApp/Views/SidebarView.swift create mode 100644 desktop/macos-claude/Sources/LionsLLM/AnthropicClient.swift create mode 100644 desktop/macos-claude/Sources/LionsLLM/JSONRepair.swift create mode 100644 desktop/macos-claude/Sources/LionsLLM/LLMClientProtocol.swift create mode 100644 desktop/macos-claude/Sources/LionsLLM/NoOpLLMClient.swift create mode 100644 desktop/macos-claude/Sources/LionsLLM/SSEParser.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/Annotations.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/AnyCodable.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/Atoms.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/DesignTokens.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/Diff.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/Guide.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/LLMUsage.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/PipelineEvent.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/RepoAnalysis.swift create mode 100644 desktop/macos-claude/Sources/LionsModels/RepoSummary.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/AnnotationBuilder.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/CrossFileContext.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/GuideBuilder.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/PipelineOrchestrator.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/PipelineProtocol.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/PromptTemplates.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/SummaryBuilder.swift create mode 100644 desktop/macos-claude/Sources/LionsPipeline/TokenBudget.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/GitDiffProvider.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/GitHubProvider.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/LanguageDetection.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/LocalFileProvider.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/SourceProvider.swift create mode 100644 desktop/macos-claude/Sources/LionsSources/URLParsing.swift create mode 100644 desktop/macos-claude/Sources/LionsStorage/GRDBStorage.swift create mode 100644 desktop/macos-claude/Sources/LionsStorage/Migrations.swift create mode 100644 desktop/macos-claude/Sources/LionsStorage/StorageError.swift create mode 100644 desktop/macos-claude/Sources/LionsStorage/StorageProtocol.swift create mode 100644 desktop/macos-claude/Tests/LionsAnalysisTests/AnalysisTests.swift create mode 100644 desktop/macos-claude/Tests/LionsLLMTests/LLMTests.swift create mode 100644 desktop/macos-claude/Tests/LionsModelsTests/ModelsTests.swift create mode 100644 desktop/macos-claude/Tests/LionsPipelineTests/PipelineTests.swift create mode 100644 desktop/macos-claude/Tests/LionsSourcesTests/SourcesTests.swift create mode 100644 desktop/macos-claude/Tests/LionsStorageTests/GRDBStorageTests.swift create mode 100644 desktop/macos-claude/Tests/UITests/SheetTextFieldTest.swift create mode 100644 desktop/macos-claude/Tests/UITests/VerifyTextField.swift create mode 100644 desktop/macos-claude/Tests/UITests/test_keyboard.sh diff --git a/desktop/macos-claude/Package.resolved b/desktop/macos-claude/Package.resolved new file mode 100644 index 0000000..d374bf6 --- /dev/null +++ b/desktop/macos-claude/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9dd9f54c2919b87f8a70b8a387b5530693a4e383593d899d270e286d4da84c9b", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + } + ], + "version" : 3 +} diff --git a/desktop/macos-claude/Package.swift b/desktop/macos-claude/Package.swift new file mode 100644 index 0000000..3bf0170 --- /dev/null +++ b/desktop/macos-claude/Package.swift @@ -0,0 +1,101 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "LionsDesktop", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "LionsApp", targets: ["LionsApp"]), + .library(name: "LionsModels", targets: ["LionsModels"]), + .library(name: "LionsStorage", targets: ["LionsStorage"]), + .library(name: "LionsSources", targets: ["LionsSources"]), + .library(name: "LionsAnalysis", targets: ["LionsAnalysis"]), + .library(name: "LionsLLM", targets: ["LionsLLM"]), + .library(name: "LionsPipeline", targets: ["LionsPipeline"]), + ], + dependencies: [ + .package(url: "https://github.com/groue/GRDB.swift", from: "6.24.0"), + ], + targets: [ + // --- Library targets --- + .target( + name: "LionsModels", + dependencies: [], + path: "Sources/LionsModels" + ), + .target( + name: "LionsStorage", + dependencies: [ + "LionsModels", + .product(name: "GRDB", package: "GRDB.swift"), + ], + path: "Sources/LionsStorage" + ), + .target( + name: "LionsSources", + dependencies: ["LionsModels"], + path: "Sources/LionsSources" + ), + .target( + name: "LionsAnalysis", + dependencies: ["LionsModels"], + path: "Sources/LionsAnalysis" + ), + .target( + name: "LionsLLM", + dependencies: ["LionsModels"], + path: "Sources/LionsLLM" + ), + .target( + name: "LionsPipeline", + dependencies: [ + "LionsModels", "LionsAnalysis", + "LionsLLM", "LionsStorage", "LionsSources", + ], + path: "Sources/LionsPipeline" + ), + .executableTarget( + name: "LionsApp", + dependencies: [ + "LionsModels", "LionsStorage", "LionsSources", + "LionsAnalysis", "LionsLLM", "LionsPipeline", + ], + path: "Sources/LionsApp" + ), + + // --- Test targets --- + .testTarget( + name: "LionsModelsTests", + dependencies: ["LionsModels"], + path: "Tests/LionsModelsTests" + ), + .testTarget( + name: "LionsStorageTests", + dependencies: ["LionsStorage", "LionsModels"], + path: "Tests/LionsStorageTests" + ), + .testTarget( + name: "LionsSourcesTests", + dependencies: ["LionsSources", "LionsModels"], + path: "Tests/LionsSourcesTests" + ), + .testTarget( + name: "LionsAnalysisTests", + dependencies: ["LionsAnalysis", "LionsModels"], + path: "Tests/LionsAnalysisTests" + ), + .testTarget( + name: "LionsLLMTests", + dependencies: ["LionsLLM", "LionsModels"], + path: "Tests/LionsLLMTests" + ), + .testTarget( + name: "LionsPipelineTests", + dependencies: [ + "LionsPipeline", "LionsModels", "LionsLLM", + "LionsStorage", "LionsSources", "LionsAnalysis", + ], + path: "Tests/LionsPipelineTests" + ), + ] +) diff --git a/desktop/macos-claude/Sources/LionsAnalysis/AnalyzerProtocol.swift b/desktop/macos-claude/Sources/LionsAnalysis/AnalyzerProtocol.swift new file mode 100644 index 0000000..a64d6cf --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/AnalyzerProtocol.swift @@ -0,0 +1,11 @@ +import Foundation +import LionsModels + +public protocol AnalyzerProtocol: Sendable { + func analyzeRepo( + allAtoms: [String: ExtendedFileAtoms], + provider: String, + resourceId: String, + version: String + ) -> RepoAnalysis +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/CallGraph.swift b/desktop/macos-claude/Sources/LionsAnalysis/CallGraph.swift new file mode 100644 index 0000000..06e597d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/CallGraph.swift @@ -0,0 +1,68 @@ +import Foundation +import LionsModels + +public func buildCallGraph( + allAtoms: [String: ExtendedFileAtoms], + symbolTable: [String: [(String, SymbolDefinition)]], + importEdges: [(String, String)] +) -> (refs: [CrossFileRef], callEdges: [(String, String)]) { + var importsOf: [String: Set] = [:] + for (fromF, toF) in importEdges { + importsOf[fromF, default: []].insert(toF) + } + + var refs: [CrossFileRef] = [] + var callEdgesSet: Set = [] + + for (filePath, atoms) in allAtoms { + let localNames = Set(atoms.definitions.map(\.name)) + + for call in atoms.callSites { + if localNames.contains(call.callee) { continue } + + if let (targetFile, targetDefn) = resolveCall( + calleeName: call.callee, fromFile: filePath, + importedFiles: importsOf[filePath] ?? [], symbolTable: symbolTable + ), targetFile != filePath { + refs.append(CrossFileRef( + fromFile: filePath, fromLine: call.line, fromSymbol: call.caller, + toFile: targetFile, toLine: targetDefn.startLine, toSymbol: targetDefn.name, + kind: "calls" + )) + let edgeKey = "\(filePath)||\(targetFile)" + if !callEdgesSet.contains(edgeKey) { + callEdgesSet.insert(edgeKey) + } + } + } + } + + let callEdges = callEdgesSet.map { key -> (String, String) in + let parts = key.split(separator: "|", maxSplits: 2).map(String.init) + return (parts[0], parts.count > 1 ? parts[1] : "") + } + + return (refs, callEdges) +} + +func resolveCall( + calleeName: String, fromFile: String, + importedFiles: Set, + symbolTable: [String: [(String, SymbolDefinition)]] +) -> (String, SymbolDefinition)? { + guard let entries = symbolTable[calleeName], !entries.isEmpty else { return nil } + + for (filePath, defn) in entries { + if importedFiles.contains(filePath) && defn.visibility == "exported" { + return (filePath, defn) + } + } + + for (filePath, defn) in entries { + if filePath != fromFile && defn.visibility == "exported" { + return (filePath, defn) + } + } + + return nil +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/ImportGraph.swift b/desktop/macos-claude/Sources/LionsAnalysis/ImportGraph.swift new file mode 100644 index 0000000..738ce0b --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/ImportGraph.swift @@ -0,0 +1,140 @@ +import Foundation +import LionsModels + +public func resolveImports( + allAtoms: [String: ExtendedFileAtoms] +) -> [(String, String)] { + let knownFiles = Set(allAtoms.keys) + var stemToFiles: [String: [String]] = [:] + for fp in knownFiles { + let stem = fileStem(fp) + stemToFiles[stem, default: []].append(fp) + let name = fileName(fp) + if name != stem { + stemToFiles[name, default: []].append(fp) + } + } + + var edges: [(String, String)] = [] + + for (filePath, atoms) in allAtoms { + let fileDir = parentDir(filePath) + let lang = atoms.language + + for imp in atoms.imports { + if let resolved = resolveSingleImport( + module: imp.module, fromFile: filePath, fileDir: fileDir, + lang: lang, knownFiles: knownFiles, stemToFiles: stemToFiles + ), resolved != filePath { + edges.append((filePath, resolved)) + } + } + } + + return edges +} + +func resolveSingleImport( + module: String, fromFile: String, fileDir: String, + lang: String, knownFiles: Set, stemToFiles: [String: [String]] +) -> String? { + switch lang { + case "c", "cpp": + if module.hasPrefix("<") || module.hasSuffix(">") { return nil } + let candidate = fileDir == "." ? module : "\(fileDir)/\(module)" + if knownFiles.contains(candidate) { return candidate } + return fuzzyMatch(name: module, stemToFiles: stemToFiles) + + case "python": + if module.hasPrefix(".") { + let relPath = String(module.drop(while: { $0 == "." })) + let parts = relPath.isEmpty ? [] : relPath.split(separator: ".").map(String.init) + if !parts.isEmpty { + let candidate = "\(fileDir)/\(parts.joined(separator: "/")).py" + if knownFiles.contains(candidate) { return candidate } + let initCandidate = "\(fileDir)/\(parts.joined(separator: "/"))/__init__.py" + if knownFiles.contains(initCandidate) { return initCandidate } + } + } else { + let parts = module.split(separator: ".").map(String.init) + let candidate = parts.joined(separator: "/") + ".py" + if knownFiles.contains(candidate) { return candidate } + let initCandidate = parts.joined(separator: "/") + "/__init__.py" + if knownFiles.contains(initCandidate) { return initCandidate } + } + let lastPart = module.split(separator: ".").last.map(String.init) ?? module + return fuzzyMatch(name: lastPart, stemToFiles: stemToFiles) + + case "typescript", "tsx", "javascript": + if !module.hasPrefix(".") { return nil } + let base = fileDir == "." ? module : "\(fileDir)/\(module)" + for ext in ["", ".ts", ".tsx", ".js", ".jsx"] { + let candidate = normalizePath(base + ext) + if knownFiles.contains(candidate) { return candidate } + } + for idx in ["/index.ts", "/index.tsx", "/index.js"] { + let candidate = normalizePath(base + idx) + if knownFiles.contains(candidate) { return candidate } + } + return nil + + case "go": + let tail = module.contains("/") ? String(module.split(separator: "/").last ?? "") : module + return fuzzyMatch(name: tail, stemToFiles: stemToFiles) + + case "rust": + let cleaned = module.replacingOccurrences(of: "crate::", with: "") + let parts = cleaned.split(separator: "::").map(String.init) + if !parts.isEmpty { + let candidate = parts.joined(separator: "/") + ".rs" + if knownFiles.contains(candidate) { return candidate } + return fuzzyMatch(name: parts.last ?? "", stemToFiles: stemToFiles) + } + return nil + + default: + return nil + } +} + +func fuzzyMatch(name: String, stemToFiles: [String: [String]]) -> String? { + let stem = fileStem(name) + if let matches = stemToFiles[stem], matches.count == 1 { return matches[0] } + if let matches = stemToFiles[name], matches.count == 1 { return matches[0] } + return nil +} + +// MARK: - Path Helpers + +func parentDir(_ path: String) -> String { + let components = path.split(separator: "/") + if components.count <= 1 { return "." } + return components.dropLast().joined(separator: "/") +} + +func fileStem(_ path: String) -> String { + let name = fileName(path) + if let dotIndex = name.lastIndex(of: ".") { + return String(name[name.startIndex.. String { + String(path.split(separator: "/").last ?? Substring(path)) +} + +/// Normalize a path by resolving "." and ".." components. +func normalizePath(_ path: String) -> String { + var parts: [String] = [] + for component in path.split(separator: "/").map(String.init) { + if component == "." { + continue + } else if component == ".." { + parts.removeLast() + } else { + parts.append(component) + } + } + return parts.joined(separator: "/") +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/PageRank.swift b/desktop/macos-claude/Sources/LionsAnalysis/PageRank.swift new file mode 100644 index 0000000..56aad8d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/PageRank.swift @@ -0,0 +1,45 @@ +import Foundation + +public func computePageRank( + files: Set, + edges: [(String, String)], + damping: Double = 0.85, + iterations: Int = 50 +) -> [String: Double] { + let n = files.count + if n == 0 { return [:] } + + var scores: [String: Double] = [:] + for f in files { scores[f] = 1.0 / Double(n) } + + var inbound: [String: [String]] = [:] + var outboundCount: [String: Int] = [:] + + for (src, dst) in edges { + guard files.contains(src), files.contains(dst) else { continue } + inbound[dst, default: []].append(src) + outboundCount[src, default: 0] += 1 + } + + for _ in 0.. 0 { + for f in files { + scores[f] = (scores[f] ?? 0) / maxScore + } + } + + return scores +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/ReadingOrder.swift b/desktop/macos-claude/Sources/LionsAnalysis/ReadingOrder.swift new file mode 100644 index 0000000..8209b63 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/ReadingOrder.swift @@ -0,0 +1,71 @@ +import Foundation + +public func computeReadingOrder( + files: Set, + importEdges: [(String, String)], + pagerankScores: [String: Double] +) -> [String] { + // Build adjacency: if A imports B, B should be read first (B -> A edge) + var adj: [String: Set] = [:] + var inDegree: [String: Int] = [:] + for f in files { inDegree[f] = 0 } + + for (src, dst) in importEdges { + guard files.contains(src), files.contains(dst), src != dst else { continue } + if adj[dst, default: []].insert(src).inserted { + inDegree[src, default: 0] += 1 + } + } + + // Break cycles via DFS back-edge detection + var visited: Set = [] + var inStack: Set = [] + var edgesToRemove: [(String, String)] = [] + + func findCycleEdges(_ node: String) { + visited.insert(node) + inStack.insert(node) + for neighbor in adj[node] ?? [] { + if inStack.contains(neighbor) { + edgesToRemove.append((node, neighbor)) + } else if !visited.contains(neighbor) { + findCycleEdges(neighbor) + } + } + inStack.remove(node) + } + + for f in files where !visited.contains(f) { + findCycleEdges(f) + } + + for (src, dst) in edgesToRemove { + adj[src]?.remove(dst) + inDegree[dst] = max(0, (inDegree[dst] ?? 0) - 1) + } + + // Kahn's algorithm with PageRank tie-breaking + var queue = files.filter { (inDegree[$0] ?? 0) == 0 } + .sorted { (pagerankScores[$0] ?? 0) > (pagerankScores[$1] ?? 0) } + var order: [String] = [] + + while !queue.isEmpty { + queue.sort { (pagerankScores[$0] ?? 0) > (pagerankScores[$1] ?? 0) } + let node = queue.removeFirst() + order.append(node) + for neighbor in adj[node] ?? [] { + inDegree[neighbor] = (inDegree[neighbor] ?? 1) - 1 + if (inDegree[neighbor] ?? 0) == 0 { + queue.append(neighbor) + } + } + } + + // Add remaining files (disconnected or in unbroken cycles) + let ordered = Set(order) + let remaining = files.filter { !ordered.contains($0) } + .sorted { (pagerankScores[$0] ?? 0) > (pagerankScores[$1] ?? 0) } + order.append(contentsOf: remaining) + + return order +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/RepoAnalyzer.swift b/desktop/macos-claude/Sources/LionsAnalysis/RepoAnalyzer.swift new file mode 100644 index 0000000..0ec4bb8 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/RepoAnalyzer.swift @@ -0,0 +1,104 @@ +import Foundation +import LionsModels + +public struct RepoAnalyzer: AnalyzerProtocol { + public init() {} + + public func analyzeRepo( + allAtoms: [String: ExtendedFileAtoms], + provider: String, + resourceId: String, + version: String + ) -> RepoAnalysis { + // 2a. Symbol table + let symbolTable = buildSymbolTable(allAtoms: allAtoms) + + // 2b. Import graph + let importEdges = resolveImports(allAtoms: allAtoms) + + // 2c. Call graph + let (crossRefs, callEdges) = buildCallGraph( + allAtoms: allAtoms, symbolTable: symbolTable, importEdges: importEdges + ) + + // Add import-based cross-file refs + var allCrossRefs = crossRefs + for (fromF, toF) in importEdges { + allCrossRefs.append(CrossFileRef( + fromFile: fromF, fromLine: 0, fromSymbol: "", + toFile: toF, toLine: 0, toSymbol: "", + kind: "imports" + )) + } + + // 2d. PageRank on combined graph + let allFiles = Set(allAtoms.keys) + let combinedEdges = Array(Set( + importEdges.map { "\($0.0)||\($0.1)" } + callEdges.map { "\($0.0)||\($0.1)" } + )).map { key -> (String, String) in + let parts = key.split(separator: "|", maxSplits: 2).map(String.init) + return (parts[0], parts.count > 1 ? parts[1] : "") + } + let prScores = computePageRank(files: allFiles, edges: combinedEdges) + + // 2e. Reading order + let readingOrder = computeReadingOrder( + files: allFiles, importEdges: importEdges, pagerankScores: prScores + ) + + // 2f. Assemble FileNode objects + var importsOf: [String: Set] = [:] + var importedBy: [String: Set] = [:] + for (src, dst) in importEdges { + importsOf[src, default: []].insert(dst) + importedBy[dst, default: []].insert(src) + } + + var callsInto: [String: Set] = [:] + var calledFrom: [String: Set] = [:] + for (src, dst) in callEdges { + callsInto[src, default: []].insert(dst) + calledFrom[dst, default: []].insert(src) + } + + var fileNodes: [FileNode] = [] + for (i, fp) in readingOrder.enumerated() { + guard let atoms = allAtoms[fp] else { continue } + fileNodes.append(FileNode( + filePath: fp, + language: atoms.language, + definitionsCount: atoms.definitions.count, + imports: (importsOf[fp] ?? []).sorted(), + importedBy: (importedBy[fp] ?? []).sorted(), + callsInto: (callsInto[fp] ?? []).sorted(), + calledFrom: (calledFrom[fp] ?? []).sorted(), + pagerank: prScores[fp] ?? 0.0, + topoOrder: i + 1 + )) + } + + // Add any files not in reading order + let orderedSet = Set(readingOrder) + for fp in allAtoms.keys.sorted() where !orderedSet.contains(fp) { + let atoms = allAtoms[fp]! + fileNodes.append(FileNode( + filePath: fp, + language: atoms.language, + definitionsCount: atoms.definitions.count, + pagerank: prScores[fp] ?? 0.0, + topoOrder: fileNodes.count + 1 + )) + } + + return RepoAnalysis( + provider: provider, + resourceId: resourceId, + version: version, + files: fileNodes, + crossReferences: allCrossRefs, + readingOrder: readingOrder, + dependencyEdges: importEdges.map { [$0.0, $0.1] }, + callGraphEdges: callEdges.map { [$0.0, $0.1] } + ) + } +} diff --git a/desktop/macos-claude/Sources/LionsAnalysis/SymbolTable.swift b/desktop/macos-claude/Sources/LionsAnalysis/SymbolTable.swift new file mode 100644 index 0000000..b3080a7 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsAnalysis/SymbolTable.swift @@ -0,0 +1,14 @@ +import Foundation +import LionsModels + +public func buildSymbolTable( + allAtoms: [String: ExtendedFileAtoms] +) -> [String: [(String, SymbolDefinition)]] { + var table: [String: [(String, SymbolDefinition)]] = [:] + for (filePath, atoms) in allAtoms { + for defn in atoms.definitions { + table[defn.name, default: []].append((filePath, defn)) + } + } + return table +} diff --git a/desktop/macos-claude/Sources/LionsApp/AppState.swift b/desktop/macos-claude/Sources/LionsApp/AppState.swift new file mode 100644 index 0000000..efeb9ef --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/AppState.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftUI +import LionsModels +import LionsStorage +import LionsSources +import LionsAnalysis +import LionsLLM + +public enum AppTheme: String, CaseIterable { + case light, dark, system +} + +@Observable +final class AppState { + var projects: [ProjectState] = [] + var selectedProjectID: String? + var apiKey: String = "" + var theme: AppTheme = .system + var storage: GRDBStorage? + + private let newProjectDialog = DialogPresenter() + + func showNewProjectDialog() { + newProjectDialog.show(title: "New Project", size: NSSize(width: 420, height: 180)) { + NewProjectSheet(appState: self, onDismiss: { + self.newProjectDialog.close() + }) + } + } + + var selectedProject: ProjectState? { + guard let id = selectedProjectID else { return nil } + return projects.first { $0.id == id } + } + + var resolvedColors: ThemeColors { + switch theme { + case .light: return DesignTokens.Colors.light + case .dark: return DesignTokens.Colors.dark + case .system: + return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? DesignTokens.Colors.dark + : DesignTokens.Colors.light + } + } + + init() { + apiKey = KeychainHelper.loadAPIKey() ?? "" + do { + storage = try GRDBStorage() + } catch { + print("Failed to init storage: \(error)") + } + } + + func addProject(provider: String, resourceId: String, version: String) { + let project = ProjectState(provider: provider, resourceId: resourceId, version: version) + projects.append(project) + selectedProjectID = project.id + + Task { @MainActor in + await loadProject(project) + } + } + + func removeProject(_ project: ProjectState) { + projects.removeAll { $0.id == project.id } + if selectedProjectID == project.id { + selectedProjectID = projects.first?.id + } + } + + // MARK: - Project Loading + + private func loadProject(_ project: ProjectState) async { + // 1. Try loading cached analysis from storage + if let storage { + await project.loadFromStorage(storage) + } + + // 2. If cached, we're done + if project.repoAnalysis != nil { return } + + // 3. Run the pipeline. Stages 1-2 (parse + analyze) work locally + // without an API key. Stages 3-5 need the LLM and will fail + // gracefully, but the analysis from Stage 2 is still stored. + guard let storage else { return } + + let source = makeSourceProvider(for: project.provider) + let client: any LLMClientProtocol = apiKey.isEmpty + ? NoOpLLMClient() + : AnthropicClient(apiKey: apiKey) + + project.runPipeline( + sourceProvider: source, + llmClient: client, + storage: storage, + model: "claude-sonnet-4-20250514" + ) { [weak self] in + // After pipeline finishes, reload results from storage + guard let self, let storage = self.storage else { return } + await project.loadFromStorage(storage) + } + } + + private func makeSourceProvider(for provider: String) -> any SourceProvider { + switch provider { + case "github_repo", "github_gist", "github_pr": + return GitHubProvider(token: nil) + default: + return LocalFileProvider() + } + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/LionsDesktopApp.swift b/desktop/macos-claude/Sources/LionsApp/LionsDesktopApp.swift new file mode 100644 index 0000000..39d37fd --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/LionsDesktopApp.swift @@ -0,0 +1,73 @@ +import SwiftUI +import LionsModels +import LionsStorage +import LionsSources +import LionsAnalysis +import LionsLLM +import LionsPipeline + +@main +struct LionsDesktopApp: App { + @NSApplicationDelegateAdaptor(LionsAppDelegate.self) var appDelegate + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + NavigationSplitView { + LibraryView(appState: appState) + } content: { + if let project = appState.selectedProject { + SidebarView(project: project, storage: appState.storage) + } else { + ContentUnavailableView("Select a Project", systemImage: "folder") + } + } detail: { + if let project = appState.selectedProject { + CodeView(project: project, colors: appState.resolvedColors) + } else { + ContentUnavailableView("Open a file", systemImage: "doc.text") + } + } + .onOpenURL { url in + handleURL(url) + } + } + .commands { + CommandGroup(replacing: .newItem) { + Button("New Project...") { + appState.showNewProjectDialog() + } + .keyboardShortcut("n") + } + } + + Settings { + SettingsView(appState: appState) + } + } + + private func handleURL(_ url: URL) { + guard url.scheme == "lions" else { return } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + let params = Dictionary(uniqueKeysWithValues: + (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + if let provider = params["provider"], + let resourceId = params["resource_id"] { + let version = params["version"] ?? "latest" + appState.addProject(provider: provider, resourceId: resourceId, version: version) + } + } +} + +/// Ensures the app registers as a regular GUI app with a dock icon. +/// Without this, SPM executables (bare binaries, no .app bundle) don't +/// get proper activation behavior -- clicking their windows from another +/// app won't bring them to the foreground. +final class LionsAppDelegate: NSObject, NSApplicationDelegate { + func applicationWillFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/ProjectState.swift b/desktop/macos-claude/Sources/LionsApp/ProjectState.swift new file mode 100644 index 0000000..113e737 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/ProjectState.swift @@ -0,0 +1,136 @@ +import Foundation +import LionsModels +import LionsStorage +import LionsSources +import LionsAnalysis +import LionsLLM +import LionsPipeline + +@Observable +final class ProjectState: Identifiable { + let id: String + let provider: String + let resourceId: String + let version: String + + var annotation: FileAnnotation? + var repoAnalysis: RepoAnalysis? + var repoSummary: RepoSummary? + var readingGuide: ReadingGuide? + + var pipelineProgress: Double = 0 + var currentStage: Stage? + var isRunning = false + var errorMessage: String? + var selectedFilePath: String? + var selectedFileSource: String? + var fileList: [String] = [] + + var displayName: String { + resourceId.split(separator: "/").last.map(String.init) ?? resourceId + } + + init(provider: String, resourceId: String, version: String) { + self.id = "\(provider)/\(resourceId)/\(version)" + self.provider = provider + self.resourceId = resourceId + self.version = version + } + + func runPipeline( + sourceProvider: any SourceProvider, + llmClient: any LLMClientProtocol, + storage: any StorageProtocol, + model: String, + onComplete: (@MainActor @Sendable () async -> Void)? = nil + ) { + guard !isRunning else { return } + isRunning = true + errorMessage = nil + pipelineProgress = 0 + + let analyzer = RepoAnalyzer() + let orchestrator = PipelineOrchestrator( + sourceProvider: sourceProvider, + analyzer: analyzer, + llmClient: llmClient, + storage: storage + ) + + let ref = SourceRef(provider: provider, resourceId: resourceId, version: version, path: "") + let stream = orchestrator.runRepoPipeline(sourceRef: ref, model: model, concurrency: 5) + + Task { @MainActor in + let stages = Stage.allCases + var completedStages = 0 + + for await event in stream { + switch event { + case .stageStarted(let stage): + currentStage = stage + print("[Pipeline] Stage started: \(stage.rawValue)") + case .stageCompleted(let stage): + completedStages += 1 + pipelineProgress = Double(completedStages) / Double(stages.count) + print("[Pipeline] Stage completed: \(stage.rawValue) (\(completedStages)/\(stages.count))") + case .fileAnnotated(let path, _): + print("[Pipeline] Annotated: \(path)") + case .fileAnnotating(let path): + print("[Pipeline] Annotating: \(path)") + case .error(let stage, let err): + print("[Pipeline] Error in \(stage.rawValue): \(err)") + // Don't overwrite error for expected LLM failures when no key + if !(err is LLMError) || errorMessage == nil { + errorMessage = err.localizedDescription + } + } + } + isRunning = false + pipelineProgress = 1.0 + currentStage = nil + await onComplete?() + } + } + + func loadFromStorage(_ storage: GRDBStorage) async { + if let stored = try? await storage.getRepoAnalysis( + provider: provider, resourceId: resourceId, version: version + ) { + if let data = stored.analysisJSON.data(using: .utf8) { + repoAnalysis = try? JSONDecoder().decode(RepoAnalysis.self, from: data) + } + if let json = stored.summaryJSON, let data = json.data(using: .utf8) { + repoSummary = try? JSONDecoder().decode(RepoSummary.self, from: data) + } + if let json = stored.readingGuideJSON, let data = json.data(using: .utf8) { + readingGuide = try? JSONDecoder().decode(ReadingGuide.self, from: data) + } + } + } + + func selectFile(path: String, storage: GRDBStorage?) { + selectedFilePath = path + annotation = nil + selectedFileSource = nil + + Task { @MainActor in + // Try loading annotation from storage + if let storage, + let stored = try? await storage.getAnnotation( + provider: provider, resourceId: resourceId, version: version, path: path + ), + let data = stored.annotationJSON.data(using: .utf8) { + annotation = try? JSONDecoder().decode(FileAnnotation.self, from: data) + } + + // If no annotation, load raw source for display + if annotation == nil { + let sourceProvider: any SourceProvider = provider.hasPrefix("github") + ? GitHubProvider(token: nil) + : LocalFileProvider() + let ref = SourceRef(provider: provider, resourceId: resourceId, version: version, path: path) + selectedFileSource = try? await sourceProvider.fetchFile(ref: ref) + } + } + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Utilities/AppKitTextField.swift b/desktop/macos-claude/Sources/LionsApp/Utilities/AppKitTextField.swift new file mode 100644 index 0000000..d37e0dc --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Utilities/AppKitTextField.swift @@ -0,0 +1,80 @@ +import AppKit +import SwiftUI + +/// NSViewRepresentable wrapping a real NSTextField that properly participates +/// in AppKit's responder chain. SwiftUI's native TextField on macOS can +/// desync its visual focus ring from the actual AppKit first responder, +/// causing keystrokes to beep instead of entering text. +struct AppKitTextField: NSViewRepresentable { + let placeholder: String + @Binding var text: String + var isSecure: Bool = false + var autoFocus: Bool = false + var onSubmit: (() -> Void)? + + func makeNSView(context: Context) -> NSTextField { + let field: NSTextField + if isSecure { + field = NSSecureTextField() + } else { + field = NSTextField() + } + field.placeholderString = placeholder + field.delegate = context.coordinator + field.bezelStyle = .roundedBezel + field.stringValue = text + field.isBordered = true + field.focusRingType = .exterior + field.cell?.sendsActionOnEndEditing = true + return field + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + // Keep coordinator's parent in sync so the binding stays current. + context.coordinator.parent = self + + if nsView.stringValue != text { + nsView.stringValue = text + } + + // Auto-focus: make this field the first responder once. + if autoFocus && !context.coordinator.didFocus { + // Delay so the window/view hierarchy is ready. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let window = nsView.window else { return } + context.coordinator.didFocus = true + window.makeFirstResponder(nsView) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: AppKitTextField + var didFocus = false + + init(_ parent: AppKitTextField) { + self.parent = parent + } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.onSubmit?() + return true + } + return false + } + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Utilities/DialogPresenter.swift b/desktop/macos-claude/Sources/LionsApp/Utilities/DialogPresenter.swift new file mode 100644 index 0000000..19b058d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Utilities/DialogPresenter.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +/// Presents a SwiftUI view in a standalone NSWindow that reliably becomes +/// the key window and accepts keyboard input. +final class DialogPresenter { + private var windowController: NSWindowController? + + func show(title: String, size: NSSize, content: @escaping () -> Content) { + close() + + let window = ActivatingWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = title + window.center() + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: content()) + + let controller = NSWindowController(window: window) + self.windowController = controller + controller.showWindow(nil) + + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } + + func close() { + windowController?.close() + windowController = nil + } +} + +/// NSWindow subclass that activates the app and becomes key on every click. +/// This handles the case where the user clicks this window while another app +/// (e.g. Finder) is frontmost -- the default behavior for bare executables +/// (no .app bundle) is to NOT activate, so keystrokes go to the wrong app. +private class ActivatingWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + override func mouseDown(with event: NSEvent) { + // Activate the app first, then let normal mouseDown handling proceed. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + makeKeyAndOrderFront(nil) + super.mouseDown(with: event) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Utilities/KeychainHelper.swift b/desktop/macos-claude/Sources/LionsApp/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..62f69a1 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Utilities/KeychainHelper.swift @@ -0,0 +1,52 @@ +import Foundation +import Security + +enum KeychainHelper { + private static let service = "com.lions.desktop" + private static let apiKeyAccount = "anthropic-api-key" + + static func save(key: String, data: Data) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + + var add = query + add[kSecValueData as String] = data + return SecItemAdd(add as CFDictionary, nil) == errSecSuccess + } + + static func load(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil } + return result as? Data + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + + static func saveAPIKey(_ apiKey: String) { + guard let data = apiKey.data(using: .utf8) else { return } + _ = save(key: apiKeyAccount, data: data) + } + + static func loadAPIKey() -> String? { + guard let data = load(key: apiKeyAccount) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/CodeView.swift b/desktop/macos-claude/Sources/LionsApp/Views/CodeView.swift new file mode 100644 index 0000000..f7bfed2 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/CodeView.swift @@ -0,0 +1,118 @@ +import SwiftUI +import AppKit +import LionsModels + +struct CodeView: View { + let project: ProjectState + let colors: ThemeColors + + var body: some View { + Group { + if let annotation = project.annotation { + AnnotatedCodeView(annotation: annotation, colors: colors) + } else if let source = project.selectedFileSource { + RawCodeView(source: source, colors: colors) + } else if project.selectedFilePath != nil { + ProgressView("Loading...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Select a file", systemImage: "doc.text") + } + } + } +} + +struct RawCodeView: View { + let source: String + let colors: ThemeColors + + var body: some View { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + let lines = source.components(separatedBy: "\n") + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 0) { + Text("\(index + 1)") + .font(Font(DesignTokens.Typography.codeFont())) + .foregroundStyle(Color(nsColor: colors.textMuted)) + .frame(width: DesignTokens.Spacing.lineNumWidth, alignment: .trailing) + .padding(.trailing, 8) + + Text(line.isEmpty ? " " : line) + .font(Font(DesignTokens.Typography.codeFont())) + .foregroundStyle(Color(nsColor: colors.text)) + .textSelection(.enabled) + } + .frame(height: DesignTokens.Spacing.lineHeight) + .padding(.horizontal, 8) + } + } + .padding(.vertical, 8) + } + .background(Color(nsColor: colors.bgCode)) + } +} + +struct AnnotatedCodeView: View { + let annotation: FileAnnotation + let colors: ThemeColors + + var body: some View { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + let lines = annotation.source.components(separatedBy: "\n") + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 0) { + // Line number + Text("\(index + 1)") + .font(Font(DesignTokens.Typography.codeFont())) + .foregroundStyle(Color(nsColor: colors.textMuted)) + .frame(width: DesignTokens.Spacing.lineNumWidth, alignment: .trailing) + .padding(.trailing, 8) + + // Source code + Text(line.isEmpty ? " " : line) + .font(Font(DesignTokens.Typography.codeFont())) + .foregroundStyle(Color(nsColor: colors.text)) + .textSelection(.enabled) + + Spacer(minLength: 16) + + // Annotation pills + let lineAnnotations = annotation.annotations.filter { $0.line == index + 1 } + ForEach(Array(lineAnnotations.enumerated()), id: \.offset) { _, ann in + AnnotationPill(annotation: ann, colors: colors) + } + } + .frame(height: DesignTokens.Spacing.lineHeight) + .padding(.horizontal, 8) + } + } + .padding(.vertical, 8) + } + .background(Color(nsColor: colors.bgCode)) + } +} + +struct AnnotationPill: View { + let annotation: MarginAnnotation + let colors: ThemeColors + + var body: some View { + let color = DesignTokens.Colors.annotation[annotation.kind] ?? DesignTokens.Colors.annotation["insight"]! + let marker = DesignTokens.annotationMarkers[annotation.kind] ?? "" + + HStack(spacing: 4) { + Text(marker) + .font(.system(size: 10)) + Text(annotation.text) + .font(Font(DesignTokens.Typography.uiFont(size: DesignTokens.Typography.captionSize))) + .lineLimit(1) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(nsColor: color).opacity(0.15)) + .foregroundStyle(Color(nsColor: color)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/LibraryView.swift b/desktop/macos-claude/Sources/LionsApp/Views/LibraryView.swift new file mode 100644 index 0000000..e4ac48b --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/LibraryView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import LionsModels + +struct LibraryView: View { + @Bindable var appState: AppState + + var body: some View { + List(selection: $appState.selectedProjectID) { + ForEach(appState.projects) { project in + ProjectRow(project: project) + .tag(project.id) + .contextMenu { + Button("Remove") { + appState.removeProject(project) + } + } + } + } + .listStyle(.sidebar) + .navigationTitle("Library") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { appState.showNewProjectDialog() }) { + Image(systemName: "plus") + } + } + } + } +} + +struct ProjectRow: View { + let project: ProjectState + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(project.displayName) + .font(.headline) + Text(project.resourceId) + .font(.caption) + .foregroundStyle(.secondary) + if project.isRunning { + ProgressView(value: project.pipelineProgress) + .progressViewStyle(.linear) + } + } + .padding(.vertical, 4) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/NewProjectSheet.swift b/desktop/macos-claude/Sources/LionsApp/Views/NewProjectSheet.swift new file mode 100644 index 0000000..458b099 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/NewProjectSheet.swift @@ -0,0 +1,75 @@ +import SwiftUI +import LionsSources + +struct NewProjectSheet: View { + @Bindable var appState: AppState + var onDismiss: () -> Void + + @State private var input = "" + @State private var provider = "local" + @State private var version = "latest" + + var body: some View { + VStack(spacing: 16) { + Text("New Project") + .font(.title2) + + AppKitTextField( + placeholder: "Local path or GitHub URL", + text: $input, + autoFocus: true + ) + .frame(height: 24) + + HStack { + Picker("Source", selection: $provider) { + Text("Local").tag("local") + Text("GitHub").tag("github_repo") + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + + AppKitTextField(placeholder: "latest", text: $version) + .frame(maxWidth: 120, maxHeight: 24) + } + + HStack { + Button("Cancel") { onDismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Detect") { + detectSource() + } + Button("Add") { + addProject() + onDismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(input.isEmpty) + } + } + .padding(24) + .frame(width: 420) + } + + private func detectSource() { + if let parsed = parseGitHubURL(input) { + switch parsed { + case .repo(let owner, let repo, let ref, _): + provider = "github_repo" + input = "\(owner)/\(repo)" + if let ref { version = ref } + case .gist(let id, _): + provider = "github_gist" + input = id + case .pullRequest(let owner, let repo, let number): + provider = "github_pr" + input = "\(owner)/\(repo)/pull/\(number)" + } + } + } + + private func addProject() { + appState.addProject(provider: provider, resourceId: input, version: version) + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/PipelineProgressView.swift b/desktop/macos-claude/Sources/LionsApp/Views/PipelineProgressView.swift new file mode 100644 index 0000000..e443c2d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/PipelineProgressView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import LionsModels + +struct PipelineProgressView: View { + let project: ProjectState + + var body: some View { + VStack(spacing: 12) { + if project.isRunning { + ProgressView(value: project.pipelineProgress) { + if let stage = project.currentStage { + Text("Stage: \(stage.rawValue)") + .font(.headline) + } + } + .progressViewStyle(.linear) + + Text("\(Int(project.pipelineProgress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 16) { + ForEach(Stage.allCases, id: \.self) { stage in + let isActive = project.currentStage == stage + let isComplete = stageIsComplete(stage) + VStack(spacing: 4) { + Circle() + .fill(isComplete ? Color.green : isActive ? Color.blue : Color.gray.opacity(0.3)) + .frame(width: 12, height: 12) + Text(stage.rawValue) + .font(.caption2) + .foregroundStyle(isActive ? .primary : .secondary) + } + } + } + } + + if let error = project.errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding() + } + + private func stageIsComplete(_ stage: Stage) -> Bool { + guard let current = project.currentStage, + let currentIndex = Stage.allCases.firstIndex(of: current), + let stageIndex = Stage.allCases.firstIndex(of: stage) else { return false } + return stageIndex < currentIndex + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/SettingsView.swift b/desktop/macos-claude/Sources/LionsApp/Views/SettingsView.swift new file mode 100644 index 0000000..84810d0 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/SettingsView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import LionsModels + +struct SettingsView: View { + @Bindable var appState: AppState + + var body: some View { + TabView { + apiKeyTab + .tabItem { Label("API Key", systemImage: "key") } + appearanceTab + .tabItem { Label("Appearance", systemImage: "paintbrush") } + aboutTab + .tabItem { Label("About", systemImage: "info.circle") } + } + .frame(width: 450, height: 250) + } + + private var apiKeyTab: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Anthropic API Key") + .font(.headline) + AppKitTextField( + placeholder: "sk-ant-...", + text: $appState.apiKey, + isSecure: true, + autoFocus: true, + onSubmit: { KeychainHelper.saveAPIKey(appState.apiKey) } + ) + .frame(height: 24) + Button("Save") { + KeychainHelper.saveAPIKey(appState.apiKey) + } + Text("Your API key is stored in the macOS Keychain.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + + private var appearanceTab: some View { + Form { + Picker("Theme", selection: $appState.theme) { + ForEach(AppTheme.allCases, id: \.self) { theme in + Text(theme.rawValue.capitalized).tag(theme) + } + } + .pickerStyle(.segmented) + } + .padding() + } + + private var aboutTab: some View { + VStack(spacing: 12) { + Text("Lions Desktop") + .font(.title2) + Text("Native macOS code annotation tool") + .foregroundStyle(.secondary) + Text("Version 0.1") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } +} diff --git a/desktop/macos-claude/Sources/LionsApp/Views/SidebarView.swift b/desktop/macos-claude/Sources/LionsApp/Views/SidebarView.swift new file mode 100644 index 0000000..8f4e7f0 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsApp/Views/SidebarView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import LionsModels +import LionsStorage + +struct SidebarView: View { + let project: ProjectState + let storage: GRDBStorage? + + var body: some View { + List { + if project.isRunning, let stage = project.currentStage { + Section("Pipeline") { + VStack(alignment: .leading, spacing: 4) { + Text(stage.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + ProgressView(value: project.pipelineProgress) + .progressViewStyle(.linear) + } + } + } + + if let error = project.errorMessage { + Section("Error") { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + + if let analysis = project.repoAnalysis { + Section("Files (\(analysis.files.count))") { + ForEach(analysis.files, id: \.filePath) { node in + Button(action: { project.selectFile(path: node.filePath, storage: storage) }) { + HStack { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + Text(node.filePath) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + Spacer() + Text("\(node.definitionsCount) defs") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + } else if !project.fileList.isEmpty { + Section("Files (\(project.fileList.count))") { + ForEach(project.fileList, id: \.self) { path in + Button(action: { project.selectFile(path: path, storage: storage) }) { + HStack { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + Text(path) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + } + } + .buttonStyle(.plain) + } + } + } + + if let annotation = project.annotation { + Section("Semantic Groups") { + ForEach(annotation.groups) { group in + VStack(alignment: .leading, spacing: 2) { + Text(group.name) + .font(.headline) + Text("Lines \(group.startLine)-\(group.endLine)") + .font(.caption) + .foregroundStyle(.secondary) + Text(group.quick) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + + if let guide = project.readingGuide { + Section("Reading Guide") { + ForEach(guide.chapters, id: \.id) { chapter in + VStack(alignment: .leading, spacing: 2) { + Text(chapter.title) + .font(.headline) + Text(chapter.summary) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + } + .listStyle(.sidebar) + .navigationTitle(project.displayName) + } +} diff --git a/desktop/macos-claude/Sources/LionsLLM/AnthropicClient.swift b/desktop/macos-claude/Sources/LionsLLM/AnthropicClient.swift new file mode 100644 index 0000000..a948038 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsLLM/AnthropicClient.swift @@ -0,0 +1,127 @@ +import Foundation +import LionsModels + +public actor AnthropicClient: LLMClientProtocol { + private let apiKey: String + private let session: URLSession + private let baseURL = "https://api.anthropic.com/v1/messages" + private let apiVersion = "2023-06-01" + + public init(apiKey: String, session: URLSession = .shared) { + self.apiKey = apiKey + self.session = session + } + + public func send( + model: String, + maxTokens: Int, + messages: [LLMMessage] + ) async throws -> (String, LLMUsage) { + let request = try buildRequest(model: model, maxTokens: maxTokens, messages: messages, stream: false) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LLMError.requestFailed(statusCode: 0, body: "No HTTP response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + if httpResponse.statusCode == 401 { throw LLMError.invalidAPIKey } + throw LLMError.requestFailed(statusCode: httpResponse.statusCode, body: body) + } + + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw LLMError.decodingError("Failed to parse response JSON") + } + + // Extract text + guard let content = dict["content"] as? [[String: Any]], + let firstBlock = content.first, + let text = firstBlock["text"] as? String else { + throw LLMError.decodingError("No text content in response") + } + + // Extract usage + let usageDict = dict["usage"] as? [String: Any] ?? [:] + let usage = LLMUsage( + inputTokens: usageDict["input_tokens"] as? Int ?? 0, + outputTokens: usageDict["output_tokens"] as? Int ?? 0, + cacheCreationTokens: usageDict["cache_creation_input_tokens"] as? Int ?? 0, + cacheReadTokens: usageDict["cache_read_input_tokens"] as? Int ?? 0 + ) + + return (text, usage) + } + + public nonisolated func stream( + model: String, + maxTokens: Int, + messages: [LLMMessage] + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let request = try await self.buildRequest( + model: model, maxTokens: maxTokens, messages: messages, stream: true + ) + let (bytes, response) = try await self.session.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw LLMError.requestFailed(statusCode: statusCode, body: "Stream error") + } + + var parser = SSEParser() + for try await line in bytes.lines { + let lineData = Data((line + "\n").utf8) + let events = parser.parse(data: lineData) + for event in events { + switch event { + case .contentBlockDelta(let text): + continuation.yield(text) + case .messageStop: + continuation.finish() + return + case .error(let msg): + throw LLMError.streamError(msg) + default: + break + } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Private + + private func buildRequest( + model: String, maxTokens: Int, messages: [LLMMessage], stream: Bool + ) throws -> URLRequest { + guard !apiKey.isEmpty else { throw LLMError.invalidAPIKey } + + guard let url = URL(string: baseURL) else { + throw LLMError.requestFailed(statusCode: 0, body: "Invalid URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue(apiVersion, forHTTPHeaderField: "anthropic-version") + + var body: [String: Any] = [ + "model": model, + "max_tokens": maxTokens, + "messages": messages.map { ["role": $0.role, "content": $0.content] }, + ] + if stream { body["stream"] = true } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } +} diff --git a/desktop/macos-claude/Sources/LionsLLM/JSONRepair.swift b/desktop/macos-claude/Sources/LionsLLM/JSONRepair.swift new file mode 100644 index 0000000..6efc90a --- /dev/null +++ b/desktop/macos-claude/Sources/LionsLLM/JSONRepair.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum JSONRepair { + /// Remove trailing commas before } or ], strip // comments, extract from markdown fences. + public static func repair(_ raw: String) -> String { + var text = raw + + // Extract from markdown fences + if text.hasPrefix("```") { + if let firstNewline = text.firstIndex(of: "\n") { + let afterFirst = text[text.index(after: firstNewline)...] + if let lastFence = afterFirst.range(of: "```", options: .backwards) { + text = String(afterFirst[afterFirst.startIndex.. Any { + var text = raw + + // Extract from markdown fences first + if text.hasPrefix("```") { + if let firstNewline = text.firstIndex(of: "\n") { + let afterFirst = text[text.index(after: firstNewline)...] + if let lastFence = afterFirst.range(of: "```", options: .backwards) { + text = String(afterFirst[afterFirst.startIndex.. (String, LLMUsage) + + func stream( + model: String, + maxTokens: Int, + messages: [LLMMessage] + ) -> AsyncThrowingStream +} diff --git a/desktop/macos-claude/Sources/LionsLLM/NoOpLLMClient.swift b/desktop/macos-claude/Sources/LionsLLM/NoOpLLMClient.swift new file mode 100644 index 0000000..7b20f1b --- /dev/null +++ b/desktop/macos-claude/Sources/LionsLLM/NoOpLLMClient.swift @@ -0,0 +1,25 @@ +import Foundation +import LionsModels + +/// An LLM client that always throws, used when no API key is configured. +/// Pipeline stages that don't require the LLM (parse, analyze) still complete; +/// stages that call the LLM will fail gracefully. +public final class NoOpLLMClient: LLMClientProtocol, @unchecked Sendable { + public init() {} + + public func send( + model: String, + maxTokens: Int, + messages: [LLMMessage] + ) async throws -> (String, LLMUsage) { + throw LLMError.invalidAPIKey + } + + public func stream( + model: String, + maxTokens: Int, + messages: [LLMMessage] + ) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish(throwing: LLMError.invalidAPIKey) } + } +} diff --git a/desktop/macos-claude/Sources/LionsLLM/SSEParser.swift b/desktop/macos-claude/Sources/LionsLLM/SSEParser.swift new file mode 100644 index 0000000..3c89839 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsLLM/SSEParser.swift @@ -0,0 +1,94 @@ +import Foundation +import LionsModels + +public enum SSEEvent: Sendable { + case contentBlockDelta(text: String) + case messageStart(messageId: String) + case messageDelta(usage: LLMUsage?) + case messageStop + case error(String) +} + +public struct SSEParser { + private var buffer = "" + + public init() {} + + public mutating func parse(data: Data) -> [SSEEvent] { + guard let text = String(data: data, encoding: .utf8) else { return [] } + buffer += text + + var events: [SSEEvent] = [] + let lines = buffer.components(separatedBy: "\n") + + var currentEventType: String? + var currentData: String? + var processedUpTo = 0 + + for (index, line) in lines.enumerated() { + if line.isEmpty { + // Empty line = end of event + if let eventType = currentEventType, let data = currentData { + if let event = parseEvent(type: eventType, data: data) { + events.append(event) + } + } + currentEventType = nil + currentData = nil + processedUpTo = index + 1 + } else if line.hasPrefix("event: ") { + currentEventType = String(line.dropFirst(7)) + processedUpTo = index + 1 + } else if line.hasPrefix("data: ") { + currentData = String(line.dropFirst(6)) + processedUpTo = index + 1 + } + } + + // Keep unprocessed lines in buffer + if processedUpTo < lines.count { + buffer = lines[processedUpTo...].joined(separator: "\n") + } else { + buffer = "" + } + + return events + } + + private func parseEvent(type: String, data: String) -> SSEEvent? { + guard let jsonData = data.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + return nil + } + + switch type { + case "message_start": + let messageId = (dict["message"] as? [String: Any])?["id"] as? String ?? "" + return .messageStart(messageId: messageId) + + case "content_block_delta": + if let delta = dict["delta"] as? [String: Any], + let text = delta["text"] as? String { + return .contentBlockDelta(text: text) + } + return nil + + case "message_delta": + if let usage = dict["usage"] as? [String: Any] { + let outputTokens = usage["output_tokens"] as? Int ?? 0 + return .messageDelta(usage: LLMUsage(outputTokens: outputTokens)) + } + return .messageDelta(usage: nil) + + case "message_stop": + return .messageStop + + case "error": + let msg = (dict["error"] as? [String: Any])?["message"] as? String ?? "Unknown error" + return .error(msg) + + default: + return nil + } + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/Annotations.swift b/desktop/macos-claude/Sources/LionsModels/Annotations.swift new file mode 100644 index 0000000..359c617 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/Annotations.swift @@ -0,0 +1,138 @@ +import Foundation + +public struct SemanticGroup: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let name: String + public let description: String + public let startLine: Int + public let endLine: Int + public var children: [String] + public let quick: String + public let deep: String + + public init(id: String, name: String, description: String, startLine: Int, + endLine: Int, children: [String] = [], quick: String, deep: String) { + self.id = id + self.name = name + self.description = description + self.startLine = startLine + self.endLine = endLine + self.children = children + self.quick = quick + self.deep = deep + } + + enum CodingKeys: String, CodingKey { + case id, name, description + case startLine = "start_line" + case endLine = "end_line" + case children, quick, deep + } +} + +public struct MarginAnnotation: Codable, Hashable, Sendable { + public let line: Int + public let kind: String + public let text: String + public var confidence: Double + + public init(line: Int, kind: String, text: String, confidence: Double = 0.9) { + self.line = line + self.kind = kind + self.text = text + self.confidence = confidence + } +} + +public struct Reference: Codable, Hashable, Sendable { + public let fromLine: Int + public let fromSymbol: String + public let toLine: Int + public let toSymbol: String + public let kind: String + + public init(fromLine: Int, fromSymbol: String, toLine: Int, toSymbol: String, kind: String) { + self.fromLine = fromLine + self.fromSymbol = fromSymbol + self.toLine = toLine + self.toSymbol = toSymbol + self.kind = kind + } + + enum CodingKeys: String, CodingKey { + case fromLine = "from_line" + case fromSymbol = "from_symbol" + case toLine = "to_line" + case toSymbol = "to_symbol" + case kind + } +} + +public struct Concept: Codable, Hashable, Sendable { + public let name: String + public let description: String + public let locations: [[String: AnyCodable]] + + public init(name: String, description: String, locations: [[String: AnyCodable]] = []) { + self.name = name + self.description = description + self.locations = locations + } +} + +public struct SourceRef: Codable, Hashable, Sendable { + public let provider: String + public let resourceId: String + public let version: String + public let path: String + + public init(provider: String, resourceId: String, version: String, path: String) { + self.provider = provider + self.resourceId = resourceId + self.version = version + self.path = path + } + + enum CodingKeys: String, CodingKey { + case provider + case resourceId = "resource_id" + case version, path + } +} + +public struct FileAnnotation: Codable, Sendable { + public var version: String + public let filePath: String + public let contentHash: String + public let metadata: [String: AnyCodable] + public let source: String + public let declarations: [[String: AnyCodable]] + public let groups: [SemanticGroup] + public let annotations: [MarginAnnotation] + public var references: [Reference] + public var concepts: [Concept] + + public init(version: String = "0.1", filePath: String, contentHash: String, + metadata: [String: AnyCodable], source: String, + declarations: [[String: AnyCodable]], + groups: [SemanticGroup], annotations: [MarginAnnotation], + references: [Reference] = [], concepts: [Concept] = []) { + self.version = version + self.filePath = filePath + self.contentHash = contentHash + self.metadata = metadata + self.source = source + self.declarations = declarations + self.groups = groups + self.annotations = annotations + self.references = references + self.concepts = concepts + } + + enum CodingKeys: String, CodingKey { + case version + case filePath = "file_path" + case contentHash = "content_hash" + case metadata, source, declarations, groups, annotations, references, concepts + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/AnyCodable.swift b/desktop/macos-claude/Sources/LionsModels/AnyCodable.swift new file mode 100644 index 0000000..ced886e --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/AnyCodable.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A type-erased Codable wrapper for arbitrary JSON values. +public struct AnyCodable: Codable, Hashable, Sendable, CustomStringConvertible { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyCodable cannot decode value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: container.codingPath, + debugDescription: "AnyCodable cannot encode value") + ) + } + } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + String(describing: lhs.value) == String(describing: rhs.value) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(String(describing: value)) + } + + public var description: String { + String(describing: value) + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/Atoms.swift b/desktop/macos-claude/Sources/LionsModels/Atoms.swift new file mode 100644 index 0000000..9641e3e --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/Atoms.swift @@ -0,0 +1,113 @@ +import Foundation + +public struct StructuralAtom: Codable, Hashable, Sendable { + public let name: String + public let kind: String + public let startLine: Int + public let endLine: Int + public let source: String + public var parent: String? + public var children: [String] + + public init(name: String, kind: String, startLine: Int, endLine: Int, + source: String, parent: String? = nil, children: [String] = []) { + self.name = name + self.kind = kind + self.startLine = startLine + self.endLine = endLine + self.source = source + self.parent = parent + self.children = children + } + + enum CodingKeys: String, CodingKey { + case name, kind + case startLine = "start_line" + case endLine = "end_line" + case source, parent, children + } +} + +public struct CallSite: Codable, Hashable, Sendable { + public let caller: String + public let callee: String + public let line: Int + public var isMethod: Bool + + public init(caller: String, callee: String, line: Int, isMethod: Bool = false) { + self.caller = caller + self.callee = callee + self.line = line + self.isMethod = isMethod + } + + enum CodingKeys: String, CodingKey { + case caller, callee, line + case isMethod = "is_method" + } +} + +public struct ImportTarget: Codable, Hashable, Sendable { + public let module: String + public var symbols: [String] + public let line: Int + + public init(module: String, symbols: [String] = [], line: Int) { + self.module = module + self.symbols = symbols + self.line = line + } +} + +public struct SymbolDefinition: Codable, Hashable, Sendable { + public let name: String + public let kind: String + public let startLine: Int + public let endLine: Int + public let visibility: String + public var parent: String? + + public init(name: String, kind: String, startLine: Int, endLine: Int, + visibility: String, parent: String? = nil) { + self.name = name + self.kind = kind + self.startLine = startLine + self.endLine = endLine + self.visibility = visibility + self.parent = parent + } + + enum CodingKeys: String, CodingKey { + case name, kind + case startLine = "start_line" + case endLine = "end_line" + case visibility, parent + } +} + +public struct ExtendedFileAtoms: Codable, Sendable { + public let filePath: String + public let language: String + public let atoms: [StructuralAtom] + public var definitions: [SymbolDefinition] + public var callSites: [CallSite] + public var imports: [ImportTarget] + + public init(filePath: String, language: String, atoms: [StructuralAtom], + definitions: [SymbolDefinition] = [], callSites: [CallSite] = [], + imports: [ImportTarget] = []) { + self.filePath = filePath + self.language = language + self.atoms = atoms + self.definitions = definitions + self.callSites = callSites + self.imports = imports + } + + enum CodingKeys: String, CodingKey { + case filePath = "file_path" + case language, atoms, definitions + case callSites = "call_sites" + case imports + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/DesignTokens.swift b/desktop/macos-claude/Sources/LionsModels/DesignTokens.swift new file mode 100644 index 0000000..2ab1a9d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/DesignTokens.swift @@ -0,0 +1,172 @@ +import AppKit +import Foundation + +// MARK: - Color Extension + +extension NSColor { + public convenience init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let r, g, b: UInt64 + switch hex.count { + case 6: + (r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + default: + (r, g, b) = (0, 0, 0) + } + self.init( + srgbRed: CGFloat(r) / 255.0, + green: CGFloat(g) / 255.0, + blue: CGFloat(b) / 255.0, + alpha: 1.0 + ) + } +} + +// MARK: - Theme Colors + +public struct ThemeColors: Sendable { + public let bg: NSColor + public let bgCode: NSColor + public let bgPanel: NSColor + public let bgHover: NSColor + public let bgActive: NSColor + public let bgNav: NSColor + public let text: NSColor + public let textSecondary: NSColor + public let textMuted: NSColor + public let accent: NSColor + public let accentBg: NSColor + public let accentLight: NSColor + public let blue: NSColor + public let border: NSColor + public let borderLight: NSColor +} + +// MARK: - Design Tokens + +public enum DesignTokens { + // MARK: Colors + public enum Colors { + public static let light = ThemeColors( + bg: NSColor(hex: "#ffffff"), + bgCode: NSColor(hex: "#f9fafb"), + bgPanel: NSColor(hex: "#ffffff"), + bgHover: NSColor(hex: "#f3f4f6"), + bgActive: NSColor(hex: "#e5e7eb"), + bgNav: NSColor(hex: "#ffffff"), + text: NSColor(hex: "#111827"), + textSecondary: NSColor(hex: "#374151"), + textMuted: NSColor(hex: "#9ca3af"), + accent: NSColor(hex: "#2563eb"), + accentBg: NSColor(hex: "#eff6ff"), + accentLight: NSColor(hex: "#3b82f6"), + blue: NSColor(hex: "#2563eb"), + border: NSColor(hex: "#e5e7eb"), + borderLight: NSColor(hex: "#f3f4f6") + ) + + public static let dark = ThemeColors( + bg: NSColor(hex: "#1c1917"), + bgCode: NSColor(hex: "#0d1117"), + bgPanel: NSColor(hex: "#292524"), + bgHover: NSColor(hex: "#292524"), + bgActive: NSColor(hex: "#44403c"), + bgNav: NSColor(hex: "#0c0a09"), + text: NSColor(hex: "#e7e5e4"), + textSecondary: NSColor(hex: "#a8a29e"), + textMuted: NSColor(hex: "#78716c"), + accent: NSColor(hex: "#fbbf24"), + accentBg: NSColor(hex: "#422006"), + accentLight: NSColor(hex: "#f59e0b"), + blue: NSColor(hex: "#60a5fa"), + border: NSColor(hex: "#44403c"), + borderLight: NSColor(hex: "#292524") + ) + + public static let annotation: [String: NSColor] = [ + "insight": NSColor(hex: "#2563eb"), + "invariant": NSColor(hex: "#ca8a04"), + "decision": NSColor(hex: "#16a34a"), + "warning": NSColor(hex: "#d97706"), + "tricky": NSColor(hex: "#dc2626"), + "difficulty": NSColor(hex: "#7c3aed"), + "reference": NSColor(hex: "#6b7280"), + "critique": NSColor(hex: "#ea580c"), + ] + + public static let bracket: [NSColor] = [ + NSColor(hex: "#8b5cf6"), + NSColor(hex: "#ef4444"), + NSColor(hex: "#f59e0b"), + NSColor(hex: "#22c55e"), + NSColor(hex: "#3b82f6"), + NSColor(hex: "#f97316"), + ] + + public static let textFaint = NSColor(hex: "#d1d5db") + } + + // MARK: Typography + public enum Typography { + public static let codeFamilyName = "Source Code Pro" + public static let codeFallbacks = ["JetBrains Mono", "Fira Code", "Menlo"] + public static let uiFamilyName = "DM Sans" + public static let proseFamilyName = "Source Serif 4" + public static let codeSize: CGFloat = 13 + public static let codeLineHeight: CGFloat = 20 + public static let captionSize: CGFloat = 11 + public static let smallSize: CGFloat = 12 + public static let bodySize: CGFloat = 13 + public static let baseSize: CGFloat = 14 + public static let headingSize: CGFloat = 16 + public static let titleSize: CGFloat = 20 + public static let displaySize: CGFloat = 24 + public static let proseSize: CGFloat = 15 + + public static func codeFont(size: CGFloat? = nil) -> NSFont { + let s = size ?? codeSize + return NSFont(name: codeFamilyName, size: s) + ?? NSFont.monospacedSystemFont(ofSize: s, weight: .regular) + } + + public static func uiFont(size: CGFloat? = nil, weight: NSFont.Weight = .regular) -> NSFont { + let s = size ?? baseSize + if let font = NSFont(name: uiFamilyName, size: s) { + return font + } + return NSFont.systemFont(ofSize: s, weight: weight) + } + } + + // MARK: Spacing + public enum Spacing { + public static let lineHeight: CGFloat = 22 + public static let gutterWidth: CGFloat = 32 + public static let rightMarginWidth: CGFloat = 260 + public static let lineNumWidth: CGFloat = 52 + public static let foldIndent: CGFloat = 16 + public static let codeMaxWidth: CGFloat = 900 + public static let proseMaxWidth: CGFloat = 720 + public static let sidebarLeft: CGFloat = 300 + public static let sidebarRight: CGFloat = 340 + } + + // MARK: Annotation Markers (geometric Unicode, no emoji) + public static let annotationMarkers: [String: String] = [ + "insight": "\u{25CF}", // filled circle + "invariant": "\u{25A0}", // filled square + "decision": "\u{25C6}", // diamond + "warning": "\u{25B2}", // filled triangle + "tricky": "\u{2B25}", // black pentagon + "difficulty": "\u{2759}", // vertical heavy bar + "reference": "\u{2014}", // em dash + "critique": "\u{25CB}", // white circle + ] + + public static let annotationKinds: [String] = [ + "insight", "invariant", "decision", "warning", + "tricky", "difficulty", "reference", "critique", + ] +} diff --git a/desktop/macos-claude/Sources/LionsModels/Diff.swift b/desktop/macos-claude/Sources/LionsModels/Diff.swift new file mode 100644 index 0000000..505bcf3 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/Diff.swift @@ -0,0 +1,90 @@ +import Foundation + +public enum FileStatus: String, Codable, Sendable { + case added, modified, deleted, renamed +} + +public enum LineType: String, Codable, Sendable { + case context, addition, deletion +} + +public struct DiffLine: Codable, Hashable, Sendable { + public let content: String + public let type: LineType + public let oldLineNumber: Int? + public let newLineNumber: Int? + + public init(content: String, type: LineType, oldLineNumber: Int? = nil, newLineNumber: Int? = nil) { + self.content = content + self.type = type + self.oldLineNumber = oldLineNumber + self.newLineNumber = newLineNumber + } + + enum CodingKeys: String, CodingKey { + case content, type + case oldLineNumber = "old_line_number" + case newLineNumber = "new_line_number" + } +} + +public struct DiffHunk: Codable, Hashable, Sendable { + public let oldStart: Int + public let oldCount: Int + public let newStart: Int + public let newCount: Int + public let lines: [DiffLine] + public var annotation: String? + + public init(oldStart: Int, oldCount: Int, newStart: Int, newCount: Int, + lines: [DiffLine], annotation: String? = nil) { + self.oldStart = oldStart + self.oldCount = oldCount + self.newStart = newStart + self.newCount = newCount + self.lines = lines + self.annotation = annotation + } + + enum CodingKeys: String, CodingKey { + case oldStart = "old_start" + case oldCount = "old_count" + case newStart = "new_start" + case newCount = "new_count" + case lines, annotation + } +} + +public struct DiffFile: Codable, Hashable, Sendable { + public let path: String + public let status: FileStatus + public let hunks: [DiffHunk] + + public init(path: String, status: FileStatus, hunks: [DiffHunk]) { + self.path = path + self.status = status + self.hunks = hunks + } +} + +public struct DiffFileAnnotation: Codable, Sendable { + public let diff: DiffFile + public let annotation: FileAnnotation? + + public init(diff: DiffFile, annotation: FileAnnotation? = nil) { + self.diff = diff + self.annotation = annotation + } +} + +public struct PRAnnotation: Codable, Sendable { + public let title: String + public let narrative: String + public let files: [DiffFileAnnotation] + + public init(title: String, narrative: String, files: [DiffFileAnnotation]) { + self.title = title + self.narrative = narrative + self.files = files + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/Guide.swift b/desktop/macos-claude/Sources/LionsModels/Guide.swift new file mode 100644 index 0000000..9b0f819 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/Guide.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct CodeReference: Codable, Hashable, Sendable { + public let startLine: Int + public let endLine: Int + public let label: String + + public init(startLine: Int, endLine: Int, label: String) { + self.startLine = startLine + self.endLine = endLine + self.label = label + } + + enum CodingKeys: String, CodingKey { + case startLine = "start_line" + case endLine = "end_line" + case label + } +} + +public struct Section: Codable, Hashable, Sendable { + public let title: String + public let content: String + public var codeReferences: [CodeReference] + + public init(title: String, content: String, codeReferences: [CodeReference] = []) { + self.title = title + self.content = content + self.codeReferences = codeReferences + } + + enum CodingKeys: String, CodingKey { + case title, content + case codeReferences = "code_references" + } +} + +public struct Chapter: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let title: String + public let summary: String + public let sections: [Section] + public var prerequisites: [String] + public var next: String? + + public init(id: String, title: String, summary: String, sections: [Section], + prerequisites: [String] = [], next: String? = nil) { + self.id = id + self.title = title + self.summary = summary + self.sections = sections + self.prerequisites = prerequisites + self.next = next + } +} + +public struct ReadingGuide: Codable, Sendable { + public var version: String + public let title: String + public let chapters: [Chapter] + + public init(version: String = "0.1", title: String, chapters: [Chapter]) { + self.version = version + self.title = title + self.chapters = chapters + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/LLMUsage.swift b/desktop/macos-claude/Sources/LionsModels/LLMUsage.swift new file mode 100644 index 0000000..1214ced --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/LLMUsage.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct LLMUsage: Codable, Sendable, Equatable { + public var inputTokens: Int + public var outputTokens: Int + public var cacheCreationTokens: Int + public var cacheReadTokens: Int + + public init(inputTokens: Int = 0, outputTokens: Int = 0, + cacheCreationTokens: Int = 0, cacheReadTokens: Int = 0) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheCreationTokens = cacheCreationTokens + self.cacheReadTokens = cacheReadTokens + } + + public static func + (lhs: LLMUsage, rhs: LLMUsage) -> LLMUsage { + LLMUsage( + inputTokens: lhs.inputTokens + rhs.inputTokens, + outputTokens: lhs.outputTokens + rhs.outputTokens, + cacheCreationTokens: lhs.cacheCreationTokens + rhs.cacheCreationTokens, + cacheReadTokens: lhs.cacheReadTokens + rhs.cacheReadTokens + ) + } + + public static func += (lhs: inout LLMUsage, rhs: LLMUsage) { + lhs = lhs + rhs + } + + public var totalTokens: Int { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + case cacheCreationTokens = "cache_creation_tokens" + case cacheReadTokens = "cache_read_tokens" + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/PipelineEvent.swift b/desktop/macos-claude/Sources/LionsModels/PipelineEvent.swift new file mode 100644 index 0000000..d21b8f0 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/PipelineEvent.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum Stage: String, Codable, Sendable, CaseIterable { + case parse, analyze, annotate, summarize, guide +} + +public enum PipelineEvent: Sendable { + case stageStarted(Stage) + case fileAnnotating(path: String) + case fileAnnotated(path: String, usage: LLMUsage) + case stageCompleted(Stage) + case error(Stage, any Error) +} diff --git a/desktop/macos-claude/Sources/LionsModels/RepoAnalysis.swift b/desktop/macos-claude/Sources/LionsModels/RepoAnalysis.swift new file mode 100644 index 0000000..6215f20 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/RepoAnalysis.swift @@ -0,0 +1,116 @@ +import Foundation + +public struct CrossFileRef: Codable, Hashable, Sendable { + public let fromFile: String + public let fromLine: Int + public let fromSymbol: String + public let toFile: String + public let toLine: Int + public let toSymbol: String + public let kind: String + + public init(fromFile: String, fromLine: Int, fromSymbol: String, + toFile: String, toLine: Int, toSymbol: String, kind: String) { + self.fromFile = fromFile + self.fromLine = fromLine + self.fromSymbol = fromSymbol + self.toFile = toFile + self.toLine = toLine + self.toSymbol = toSymbol + self.kind = kind + } + + enum CodingKeys: String, CodingKey { + case fromFile = "from_file" + case fromLine = "from_line" + case fromSymbol = "from_symbol" + case toFile = "to_file" + case toLine = "to_line" + case toSymbol = "to_symbol" + case kind + } +} + +public struct FileNode: Codable, Hashable, Sendable { + public let filePath: String + public let language: String + public let definitionsCount: Int + public var imports: [String] + public var importedBy: [String] + public var callsInto: [String] + public var calledFrom: [String] + public var pagerank: Double + public var topoOrder: Int + + public init(filePath: String, language: String, definitionsCount: Int, + imports: [String] = [], importedBy: [String] = [], + callsInto: [String] = [], calledFrom: [String] = [], + pagerank: Double = 0.0, topoOrder: Int = 0) { + self.filePath = filePath + self.language = language + self.definitionsCount = definitionsCount + self.imports = imports + self.importedBy = importedBy + self.callsInto = callsInto + self.calledFrom = calledFrom + self.pagerank = pagerank + self.topoOrder = topoOrder + } + + enum CodingKeys: String, CodingKey { + case filePath = "file_path" + case language + case definitionsCount = "definitions_count" + case imports + case importedBy = "imported_by" + case callsInto = "calls_into" + case calledFrom = "called_from" + case pagerank + case topoOrder = "topo_order" + } +} + +public struct RepoAnalysis: Codable, Sendable { + public let provider: String + public let resourceId: String + public let version: String + public let files: [FileNode] + public let crossReferences: [CrossFileRef] + public let readingOrder: [String] + public let dependencyEdges: [[String]] + public let callGraphEdges: [[String]] + + public init(provider: String, resourceId: String, version: String, + files: [FileNode], crossReferences: [CrossFileRef], + readingOrder: [String], dependencyEdges: [[String]], + callGraphEdges: [[String]]) { + self.provider = provider + self.resourceId = resourceId + self.version = version + self.files = files + self.crossReferences = crossReferences + self.readingOrder = readingOrder + self.dependencyEdges = dependencyEdges + self.callGraphEdges = callGraphEdges + } + + /// Convenience to access dependency edges as tuples. + public var dependencyEdgePairs: [(String, String)] { + dependencyEdges.compactMap { $0.count == 2 ? ($0[0], $0[1]) : nil } + } + + /// Convenience to access call graph edges as tuples. + public var callGraphEdgePairs: [(String, String)] { + callGraphEdges.compactMap { $0.count == 2 ? ($0[0], $0[1]) : nil } + } + + enum CodingKeys: String, CodingKey { + case provider + case resourceId = "resource_id" + case version, files + case crossReferences = "cross_references" + case readingOrder = "reading_order" + case dependencyEdges = "dependency_edges" + case callGraphEdges = "call_graph_edges" + } +} diff --git a/desktop/macos-claude/Sources/LionsModels/RepoSummary.swift b/desktop/macos-claude/Sources/LionsModels/RepoSummary.swift new file mode 100644 index 0000000..e750bc5 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsModels/RepoSummary.swift @@ -0,0 +1,86 @@ +import Foundation + +public struct FileSummary: Codable, Hashable, Sendable { + public let filePath: String + public let oneLiner: String + public let role: String + public var keyExports: [String] + + public init(filePath: String, oneLiner: String, role: String, keyExports: [String] = []) { + self.filePath = filePath + self.oneLiner = oneLiner + self.role = role + self.keyExports = keyExports + } + + enum CodingKeys: String, CodingKey { + case filePath = "file_path" + case oneLiner = "one_liner" + case role + case keyExports = "key_exports" + } +} + +public struct ReadingStep: Codable, Hashable, Sendable { + public let filePath: String + public let order: Int + public let narrative: String + + public init(filePath: String, order: Int, narrative: String) { + self.filePath = filePath + self.order = order + self.narrative = narrative + } + + enum CodingKeys: String, CodingKey { + case filePath = "file_path" + case order, narrative + } +} + +public struct FileCluster: Codable, Hashable, Sendable { + public let name: String + public var description: String + public var files: [String] + + public init(name: String, description: String = "", files: [String] = []) { + self.name = name + self.description = description + self.files = files + } +} + +public struct RepoSummary: Codable, Sendable { + public let provider: String + public let resourceId: String + public let version: String + public let summary: String + public var fileSummaries: [FileSummary] + public var readingOrder: [ReadingStep] + public var entryPoints: [String] + public var fileClusters: [FileCluster] + + public init(provider: String, resourceId: String, version: String, + summary: String, fileSummaries: [FileSummary] = [], + readingOrder: [ReadingStep] = [], entryPoints: [String] = [], + fileClusters: [FileCluster] = []) { + self.provider = provider + self.resourceId = resourceId + self.version = version + self.summary = summary + self.fileSummaries = fileSummaries + self.readingOrder = readingOrder + self.entryPoints = entryPoints + self.fileClusters = fileClusters + } + + enum CodingKeys: String, CodingKey { + case provider + case resourceId = "resource_id" + case version, summary + case fileSummaries = "file_summaries" + case readingOrder = "reading_order" + case entryPoints = "entry_points" + case fileClusters = "file_clusters" + } +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/AnnotationBuilder.swift b/desktop/macos-claude/Sources/LionsPipeline/AnnotationBuilder.swift new file mode 100644 index 0000000..8e9049d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/AnnotationBuilder.swift @@ -0,0 +1,149 @@ +import CryptoKit +import Foundation +import LionsModels +import LionsLLM + +public enum AnnotationBuilder { + /// Build the annotation prompt for a single file. + /// Port of _build_prompt from stage3_annotate.py. + public static func buildPrompt( + source: String, + filePath: String, + atoms: [StructuralAtom], + crossFileContext: String? = nil + ) -> (prompt: String, declarations: [[String: AnyCodable]]) { + let declarations: [[String: AnyCodable]] = atoms.map { a in + [ + "name": AnyCodable(a.name), + "kind": AnyCodable(a.kind), + "start_line": AnyCodable(a.startLine), + "end_line": AnyCodable(a.endLine), + ] + } + + let numbered = source.split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + .map { (i, line) in String(format: "%4d | %@", i + 1, String(line)) } + .joined(separator: "\n") + + let declJSON: String + if let data = try? JSONEncoder().encode(declarations), + let str = String(data: data, encoding: .utf8) { + declJSON = str + } else { + declJSON = "[]" + } + + var prompt = """ + \(PromptTemplates.annotation) + + File: \(filePath) + + Structural declarations: + \(declJSON) + + Source code: + \(numbered) + """ + + if let ctx = crossFileContext, !ctx.isEmpty { + prompt += "\n\n\(ctx)" + } + + return (prompt, declarations) + } + + /// Parse Claude's response into a FileAnnotation. + /// Port of _parse_response from stage3_annotate.py. + public static func parseResponse( + raw: String, + source: String, + filePath: String, + declarations: [[String: AnyCodable]], + model: String + ) throws -> FileAnnotation { + let cleaned: String + do { + let parsed = try JSONRepair.parseJSON(raw) + if let data = try? JSONSerialization.data(withJSONObject: parsed), + let str = String(data: data, encoding: .utf8) { + cleaned = str + } else { + cleaned = raw + } + } catch { + throw error + } + + guard let jsonData = cleaned.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw LLMError.decodingError("Failed to parse JSON response") + } + + let decoder = JSONDecoder() + + // Parse groups + var groups: [SemanticGroup] = [] + if let groupsArray = dict["groups"] as? [[String: Any]] { + for g in groupsArray { + if let data = try? JSONSerialization.data(withJSONObject: g), + let group = try? decoder.decode(SemanticGroup.self, from: data) { + groups.append(group) + } + } + } + + // Parse annotations + var annotations: [MarginAnnotation] = [] + if let annsArray = dict["annotations"] as? [[String: Any]] { + for a in annsArray { + if let data = try? JSONSerialization.data(withJSONObject: a), + let ann = try? decoder.decode(MarginAnnotation.self, from: data) { + annotations.append(ann) + } + } + } + + // Parse references + var references: [Reference] = [] + if let refsArray = dict["references"] as? [[String: Any]] { + for r in refsArray { + if let data = try? JSONSerialization.data(withJSONObject: r), + let ref = try? decoder.decode(Reference.self, from: data) { + references.append(ref) + } + } + } + + // Parse concepts + var concepts: [Concept] = [] + if let conceptsArray = dict["concepts"] as? [[String: Any]] { + for c in conceptsArray { + if let data = try? JSONSerialization.data(withJSONObject: c), + let concept = try? decoder.decode(Concept.self, from: data) { + concepts.append(concept) + } + } + } + + let contentHash = SHA256.hash(data: Data(source.utf8)) + .compactMap { String(format: "%02x", $0) } + .joined() + + return FileAnnotation( + filePath: filePath, + contentHash: contentHash, + metadata: [ + "generated_at": AnyCodable(""), + "pipeline_version": AnyCodable("0.1"), + "model": AnyCodable(model), + ], + source: source, + declarations: declarations, + groups: groups, + annotations: annotations, + references: references, + concepts: concepts + ) + } +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/CrossFileContext.swift b/desktop/macos-claude/Sources/LionsPipeline/CrossFileContext.swift new file mode 100644 index 0000000..4a01dcd --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/CrossFileContext.swift @@ -0,0 +1,51 @@ +import Foundation +import LionsModels + +public enum CrossFileContext { + /// Build cross-file context string from RepoAnalysis for LLM prompt injection. + /// Port of build_cross_file_context from stage3_annotate.py. + public static func build(filePath: String, analysis: RepoAnalysis) -> String { + guard let fileNode = analysis.files.first(where: { $0.filePath == filePath }) else { + return "" + } + + var parts = ["## Cross-File Context", "This file is part of a larger codebase.", ""] + + if !fileNode.importedBy.isEmpty { + parts.append("Files that depend on this file: \(fileNode.importedBy.joined(separator: ", "))") + } + if !fileNode.imports.isEmpty { + parts.append("Files this file depends on: \(fileNode.imports.joined(separator: ", "))") + } + + let crossFileCalls = analysis.crossReferences.filter { + $0.toFile == filePath && $0.kind == "calls" + } + if !crossFileCalls.isEmpty { + var symbolCallers: [String: Set] = [:] + for ref in crossFileCalls { + symbolCallers[ref.toSymbol, default: []].insert(ref.fromFile) + } + parts.append("") + parts.append("Key symbols defined here used elsewhere:") + for (sym, callers) in symbolCallers.sorted(by: { $0.value.count > $1.value.count }) { + let plural = callers.count > 1 ? "s" : "" + parts.append("- \(sym) (called from \(callers.count) file\(plural))") + } + } + + if let pos = analysis.readingOrder.firstIndex(of: filePath) { + let position = pos + 1 + let total = analysis.readingOrder.count + let ranked = analysis.files.sorted { $0.pagerank > $1.pagerank } + let rank = ranked.firstIndex(where: { $0.filePath == filePath }).map { $0 + 1 } ?? 0 + parts.append("\nReading order position: \(position) of \(total) (importance rank: #\(rank))") + } + + parts.append("") + parts.append("Use this context to make annotations aware of how this file fits the larger system.") + parts.append("When annotating calls to functions in OTHER files, mention the target file.") + + return parts.joined(separator: "\n") + } +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/GuideBuilder.swift b/desktop/macos-claude/Sources/LionsPipeline/GuideBuilder.swift new file mode 100644 index 0000000..38472f2 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/GuideBuilder.swift @@ -0,0 +1,85 @@ +import Foundation +import LionsModels + +public enum GuideBuilder { + /// Build input context for guide generation. + /// Port of _build_guide_input from stage5_guide.py. + public static func buildGuideInput( + summary: RepoSummary, + analysis: RepoAnalysis, + annotationSummaries: [String: [String: Any]]? = nil + ) -> String { + var parts = [ + "Repository: \(analysis.resourceId)", + "Total files: \(analysis.files.count)", + "", + ] + + if !summary.summary.isEmpty { + parts.append("## Repository Overview:") + parts.append(summary.summary) + parts.append("") + } + + if !summary.entryPoints.isEmpty { + parts.append("## Entry Points: \(summary.entryPoints.joined(separator: ", "))") + parts.append("") + } + + parts.append("## File Clusters:") + for cluster in summary.fileClusters { + parts.append("\n### \(cluster.name) (\(cluster.files.count) files)") + parts.append(cluster.description) + for fp in cluster.files { + let fs = summary.fileSummaries.first { $0.filePath == fp } + let oneLiner = fs.map { " \u{2014} \($0.oneLiner)" } ?? "" + let role = fs.map { " [\($0.role)]" } ?? "" + parts.append(" - \(fp)\(role)\(oneLiner)") + } + } + + parts.append("\n## Suggested Reading Order:") + for step in summary.readingOrder { + parts.append(" \(step.order). \(step.filePath): \(step.narrative)") + } + + parts.append("\n## Key Dependencies:") + for (src, dst) in analysis.dependencyEdgePairs.prefix(30) { + parts.append(" \(src) \u{2192} \(dst)") + } + + if !analysis.callGraphEdgePairs.isEmpty { + parts.append("\n## Cross-File Calls:") + for (src, dst) in analysis.callGraphEdgePairs.prefix(20) { + parts.append(" \(src) \u{2192} \(dst)") + } + } + + parts.append("\n## File Details:") + for node in analysis.files.sorted(by: { $0.pagerank > $1.pagerank }) { + parts.append(" \(node.filePath): \(node.definitionsCount) definitions, PageRank \(String(format: "%.2f", node.pagerank))") + } + + if let summaries = annotationSummaries { + parts.append("\n## Per-File Annotations (semantic groups):") + for (fp, info) in summaries { + let groups = info["groups"] as? [[String: Any]] ?? [] + let concepts = info["concepts"] as? [String] ?? [] + if !groups.isEmpty || !concepts.isEmpty { + parts.append("\n \(fp):") + for g in groups { + let name = g["name"] as? String ?? "" + let start = g["start_line"] as? Int ?? 0 + let end = g["end_line"] as? Int ?? 0 + parts.append(" Group: \(name) (lines \(start)-\(end))") + } + for c in concepts { + parts.append(" Concept: \(c)") + } + } + } + } + + return parts.joined(separator: "\n") + } +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/PipelineOrchestrator.swift b/desktop/macos-claude/Sources/LionsPipeline/PipelineOrchestrator.swift new file mode 100644 index 0000000..06df827 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/PipelineOrchestrator.swift @@ -0,0 +1,330 @@ +import CryptoKit +import Foundation +import LionsAnalysis +import LionsLLM +import LionsModels +import LionsSources +import LionsStorage + +public final class PipelineOrchestrator: PipelineProtocol, Sendable { + private let sourceProvider: any SourceProvider + private let analyzer: any AnalyzerProtocol + private let llmClient: any LLMClientProtocol + private let storage: any StorageProtocol + + public init( + sourceProvider: any SourceProvider, + analyzer: any AnalyzerProtocol, + llmClient: any LLMClientProtocol, + storage: any StorageProtocol + ) { + self.sourceProvider = sourceProvider + self.analyzer = analyzer + self.llmClient = llmClient + self.storage = storage + } + + public func runRepoPipeline( + sourceRef: SourceRef, + model: String, + concurrency: Int + ) -> AsyncStream { + AsyncStream { continuation in + Task { + do { + // Resolve version + let version = try await sourceProvider.resolveVersion( + resourceId: sourceRef.resourceId, version: sourceRef.version + ) + let ref = SourceRef( + provider: sourceRef.provider, resourceId: sourceRef.resourceId, + version: version, path: sourceRef.path + ) + + // Stage 1: Parse + continuation.yield(.stageStarted(.parse)) + let filePaths = try await sourceProvider.listFiles(ref: ref) + let supportedFiles = filePaths.filter { LanguageDetection.isSupported(filePath: $0) } + + var allAtoms: [String: ExtendedFileAtoms] = [:] + for path in supportedFiles { + let fileRef = SourceRef( + provider: ref.provider, resourceId: ref.resourceId, + version: ref.version, path: path + ) + let source = try await sourceProvider.fetchFile(ref: fileRef) + let lang = LanguageDetection.detect(filePath: path) ?? "unknown" + // Stage 1 is normally tree-sitter parsing, but without the parser module + // we create minimal atoms from the source + let atoms = ExtendedFileAtoms( + filePath: path, language: lang, atoms: [] + ) + allAtoms[path] = atoms + } + continuation.yield(.stageCompleted(.parse)) + + // Stage 2: Analyze + continuation.yield(.stageStarted(.analyze)) + let analysis = analyzer.analyzeRepo( + allAtoms: allAtoms, provider: ref.provider, + resourceId: ref.resourceId, version: ref.version + ) + // Store analysis + if let jsonData = try? JSONEncoder().encode(analysis), + let jsonStr = String(data: jsonData, encoding: .utf8) { + try? await storage.storeRepoAnalysis( + provider: ref.provider, resourceId: ref.resourceId, + version: ref.version, analysisJSON: jsonStr, displayName: ref.resourceId + ) + } + continuation.yield(.stageCompleted(.analyze)) + + // Stage 3: Annotate files concurrently + continuation.yield(.stageStarted(.annotate)) + var totalUsage = LLMUsage() + + await withTaskGroup(of: (String, FileAnnotation, LLMUsage)?.self) { group in + var running = 0 + var fileQueue = supportedFiles.makeIterator() + + func launchNext() -> Bool { + guard let path = fileQueue.next() else { return false } + group.addTask { [self] in + let fileRef = SourceRef( + provider: ref.provider, resourceId: ref.resourceId, + version: ref.version, path: path + ) + guard let source = try? await self.sourceProvider.fetchFile(ref: fileRef) else { + return nil + } + let atoms = allAtoms[path]?.atoms ?? [] + let crossCtx = CrossFileContext.build(filePath: path, analysis: analysis) + let (prompt, declarations) = AnnotationBuilder.buildPrompt( + source: source, filePath: path, atoms: atoms, + crossFileContext: crossCtx + ) + let maxTokens = TokenBudget.maxTokensForSource(source) + + for attempt in 0.. FileAnnotation { + let atoms: [StructuralAtom] = [] + let (prompt, declarations) = AnnotationBuilder.buildPrompt( + source: source, filePath: filePath, atoms: atoms + ) + let maxTokens = TokenBudget.maxTokensForSource(source) + + for attempt in 0.. AsyncStream + + func runSingleFile( + source: String, + filePath: String, + model: String + ) async throws -> FileAnnotation +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/PromptTemplates.swift b/desktop/macos-claude/Sources/LionsPipeline/PromptTemplates.swift new file mode 100644 index 0000000..0cd01a3 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/PromptTemplates.swift @@ -0,0 +1,113 @@ +import Foundation + +public enum PromptTemplates { + public static let annotation = """ + You are an expert code educator creating annotations for a code learning tool called Lions. + + You will be given a Python source file. Produce a JSON object with these fields: + + 1. "groups": Semantic groups that organize the code into logical sections. + Each group has: id, name, description, start_line, end_line, children (list of child group ids), quick (1-2 sentence intuitive explanation), deep (detailed technical explanation, 2-4 paragraphs). + + 2. "annotations": Margin annotations for specific lines. + Each has: line (1-indexed), kind (one of: insight, invariant, decision, warning, reference, tricky, critique, difficulty), text, confidence (0-1). + - insight: non-obvious understanding + - invariant: condition that must hold + - decision: why this approach was chosen + - warning: potential pitfall + - reference: pointer to related code/concept + - tricky: subtle or clever code + - critique: how this could be improved + - difficulty: rates complexity for learners + + 3. "references": Cross-references between code locations. + Each has: from_line, from_symbol, to_line, to_symbol, kind (calls|uses|implements|overrides). + + 4. "concepts": Key concepts taught by this code. + Each has: name, description, locations (list of {file, line, context}). + + Guidelines: + - Write for a developer who knows Python but is learning ML/transformers + - "quick" should be intuitive and accessible (like Karpathy's blog style) + - "deep" should be precise and thorough (like a textbook) + - Aim for ~15-25 margin annotations for a 200-line file + - Create 5-8 semantic groups + - Identify 5-10 key concepts + - Be concrete: use actual values from the code in explanations + - Flag genuinely tricky parts, don't over-annotate obvious code + + Return ONLY valid JSON, no markdown fences. + """ + + public static let cluster = """ + You are an expert code educator. Given metadata about a cluster of related source files \ + in a repository, generate a brief summary. + + Return a JSON object with: + 1. "description": one-sentence description of what this cluster of files does together + 2. "file_summaries": for each file, provide: + - "file_path": the file path + - "one_liner": one-sentence description of what the file does + - "role": one of "core", "utility", "tests", "config", "data_model" + - "key_exports": list of the most important exported symbols + + Be concise. Return ONLY valid JSON, no markdown fences. + """ + + public static let synthesis = """ + You are an expert code educator. Given summaries of file clusters in a repository, \ + generate a repo-level overview. + + Return a JSON object with: + 1. "summary": A 2-3 paragraph overview of the repository. What does it do? Key design decisions? Who would use it? + 2. "reading_order": Guided reading steps, each with: + - "file_path": the file path + - "order": 1-indexed position + - "narrative": why to read this file at this point (e.g., "Start here because...") + Only include the top 10-15 most important files in the reading order. + 3. "entry_points": list of 1-3 files that are the best starting points + + Be concise. Return ONLY valid JSON, no markdown fences. + """ + + public static let guide = """ + You are an expert code educator creating a structured reading guide for a codebase. + Given a repository summary, file clusters, and cross-file analysis, produce a ReadingGuide \ + that teaches the codebase progressively. + + Return a JSON object with: + { + "title": "Reading Guide: ", + "chapters": [ + { + "id": "ch_01", + "title": "Chapter title", + "summary": "One paragraph overview of what this chapter covers", + "sections": [ + { + "title": "Section title", + "content": "2-4 paragraphs of prose explaining the concept. Reference specific files and functions. Use markdown formatting for emphasis.", + "code_references": [ + {"start_line": 1, "end_line": 15, "label": "file_path.py — function_name"} + ] + } + ], + "prerequisites": ["ch_00"], + "next": "ch_02" + } + ] + } + + Guidelines: + - Create 3-7 chapters depending on repo complexity + - Order chapters pedagogically: foundations -> core logic -> advanced topics -> testing/utils + - Each chapter should focus on 1-3 related files + - Each section should explain ONE concept or component + - Code references must use actual file paths and valid line ranges from the source + - The content should explain WHY the code works this way, not just WHAT it does + - Write for a developer who is new to this codebase but experienced with the language + - Be concise but thorough — each section should be 2-4 paragraphs + + Return ONLY valid JSON, no markdown fences. + """ +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/SummaryBuilder.swift b/desktop/macos-claude/Sources/LionsPipeline/SummaryBuilder.swift new file mode 100644 index 0000000..3e72d4d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/SummaryBuilder.swift @@ -0,0 +1,258 @@ +import Foundation +import LionsModels + +public enum SummaryBuilder { + // MARK: - Constants + + static let maxClusterSize = 25 + static let minClusterSize = 2 + + // MARK: - 4a. Algorithmic Clustering + + /// Cluster files using directory structure refined by import/call graph edges. + /// Port of cluster_files from stage4_summarize.py. + public static func clusterFiles(analysis: RepoAnalysis) -> [FileCluster] { + let files = analysis.files.map(\.filePath) + if files.isEmpty { return [] } + + // Step 1: Directory-based grouping + var dirGroups: [String: [String]] = [:] + for fp in files { + let parent = parentDirectory(fp) + dirGroups[parent, default: []].append(fp) + } + + // Step 2: Build connectivity between directories + let allEdges = Set(analysis.dependencyEdgePairs.map { Edge($0.0, $0.1) } + + analysis.callGraphEdgePairs.map { Edge($0.0, $0.1) }) + let fileToDir: [String: String] = Dictionary(uniqueKeysWithValues: files.map { ($0, parentDirectory($0)) }) + var dirConnectivity: [String: Int] = [:] + for edge in allEdges { + guard let srcDir = fileToDir[edge.src], let dstDir = fileToDir[edge.dst], + srcDir != dstDir else { continue } + let pair = [srcDir, dstDir].sorted().joined(separator: "|||") + dirConnectivity[pair, default: 0] += 1 + } + + // Step 3: Merge small clusters + var clusters: [String: Set] = dirGroups.mapValues { Set($0) } + var changed = true + while changed { + changed = false + let small = clusters.filter { $0.value.count < minClusterSize }.map(\.key) + for d in small { + guard clusters[d] != nil else { continue } + var bestNeighbor: String? + var bestWeight = 0 + for otherD in clusters.keys where otherD != d { + let pair = [d, otherD].sorted().joined(separator: "|||") + var weight = dirConnectivity[pair] ?? 0 + if sharedPrefixDepth(d, otherD) > 0 { weight += 1 } + if weight > bestWeight { + bestWeight = weight + bestNeighbor = otherD + } + } + if bestNeighbor == nil { + bestNeighbor = clusters.keys.filter { $0 != d }.max(by: { clusters[$0]!.count < clusters[$1]!.count }) + } + if let neighbor = bestNeighbor { + let toMerge = clusters[d]! + clusters[neighbor]?.formUnion(toMerge) + clusters.removeValue(forKey: d) + changed = true + } + } + } + + // Step 4: Split oversized clusters + let prMap = Dictionary(uniqueKeysWithValues: analysis.files.map { ($0.filePath, $0.pagerank) }) + var finalClusters: [String: Set] = [:] + for (dirName, fileSet) in clusters { + if fileSet.count > maxClusterSize { + let splits = splitCluster(files: Array(fileSet), edges: Array(allEdges), prMap: prMap) + for (i, split) in splits.enumerated() { + finalClusters["\(dirName)#\(i)"] = Set(split) + } + } else { + finalClusters[dirName] = fileSet + } + } + + // Step 5: Generate cluster names + return finalClusters + .sorted { $0.value.count > $1.value.count } + .map { (_, fileSet) in + FileCluster(name: clusterName(files: Array(fileSet).sorted()), files: Array(fileSet).sorted()) + } + } + + // MARK: - Prompt Input Builders + + /// Build input text for a single cluster's LLM call. + /// Port of _build_cluster_input from stage4_summarize.py. + public static func buildClusterInput(cluster: FileCluster, analysis: RepoAnalysis) -> String { + let nodeMap = Dictionary(uniqueKeysWithValues: analysis.files.map { ($0.filePath, $0) }) + var parts = ["Cluster: \(cluster.name)", "Files: \(cluster.files.count)", ""] + + for fp in cluster.files { + if let node = nodeMap[fp] { + var extras = "" + if !node.imports.isEmpty { extras += ", imports: \(node.imports.joined(separator: ", "))" } + if !node.importedBy.isEmpty { extras += ", imported by: \(node.importedBy.joined(separator: ", "))" } + parts.append("- \(fp) (\(node.definitionsCount) defs, PageRank: \(String(format: "%.2f", node.pagerank))\(extras))") + } else { + parts.append("- \(fp)") + } + } + + let clusterFiles = Set(cluster.files) + let relevantCalls = analysis.crossReferences.filter { + $0.kind == "calls" && (clusterFiles.contains($0.fromFile) || clusterFiles.contains($0.toFile)) + } + if !relevantCalls.isEmpty { + parts.append("") + parts.append("Cross-file calls:") + for ref in relevantCalls.prefix(20) { + parts.append(" \(ref.fromFile)::\(ref.fromSymbol) -> \(ref.toFile)::\(ref.toSymbol)") + } + } + + return parts.joined(separator: "\n") + } + + /// Build input for the final synthesis call. + /// Port of _build_synthesis_input from stage4_summarize.py. + public static func buildSynthesisInput( + clusters: [FileCluster], + fileSummaries: [FileSummary], + analysis: RepoAnalysis + ) -> String { + var parts = ["Repository: \(analysis.resourceId)", "Total files: \(analysis.files.count)", ""] + + parts.append("## Clusters:") + for cluster in clusters { + parts.append("\n### \(cluster.name) (\(cluster.files.count) files)") + parts.append(cluster.description) + for fp in cluster.files { + let fs = fileSummaries.first { $0.filePath == fp } + let oneLiner = fs.map { " \u{2014} \($0.oneLiner)" } ?? "" + parts.append(" - \(fp)\(oneLiner)") + } + } + + let ranked = analysis.files.sorted { $0.pagerank > $1.pagerank }.prefix(15) + parts.append("\n## Most Important Files (by PageRank):") + for (i, node) in ranked.enumerated() { + parts.append("\(i + 1). \(node.filePath) (PageRank: \(String(format: "%.2f", node.pagerank)), \(node.definitionsCount) defs)") + } + + parts.append("\n## Algorithmic Reading Order:") + for (i, fp) in analysis.readingOrder.prefix(15).enumerated() { + parts.append("\(i + 1). \(fp)") + } + + return parts.joined(separator: "\n") + } + + // MARK: - Helpers + + struct Edge: Hashable { + let src: String + let dst: String + init(_ src: String, _ dst: String) { self.src = src; self.dst = dst } + } + + static func parentDirectory(_ path: String) -> String { + let components = path.split(separator: "/") + if components.count <= 1 { return "." } + return components.dropLast().joined(separator: "/") + } + + static func sharedPrefixDepth(_ a: String, _ b: String) -> Int { + let aParts = a.split(separator: "/") + let bParts = b.split(separator: "/") + var shared = 0 + for (ap, bp) in zip(aParts, bParts) { + if ap == bp { shared += 1 } else { break } + } + return shared + } + + static func splitCluster(files: [String], edges: [Edge], prMap: [String: Double]) -> [[String]] { + let fileSet = Set(files) + var adj: [String: Set] = [:] + for edge in edges { + if fileSet.contains(edge.src) && fileSet.contains(edge.dst) { + adj[edge.src, default: []].insert(edge.dst) + adj[edge.dst, default: []].insert(edge.src) + } + } + + var visited: Set = [] + var components: [[String]] = [] + for f in files { + if visited.contains(f) { continue } + var component: [String] = [] + var queue = [f] + while !queue.isEmpty { + let node = queue.removeFirst() + if visited.contains(node) { continue } + visited.insert(node) + component.append(node) + for neighbor in adj[node] ?? [] where !visited.contains(neighbor) { + queue.append(neighbor) + } + } + components.append(component) + } + + var result: [[String]] = [] + for component in components { + if component.count > maxClusterSize { + var subDirs: [String: [String]] = [:] + for fp in component { + subDirs[parentDirectory(fp), default: []].append(fp) + } + for subFiles in subDirs.values { + result.append(subFiles) + } + } else { + result.append(component) + } + } + + return result.isEmpty ? [files] : result + } + + static func clusterName(files: [String]) -> String { + if files.isEmpty { return "Other" } + + let partsList = files.map { path -> [String] in + let components = path.split(separator: "/").map(String.init) + return components.count > 1 ? Array(components.dropLast()) : [] + } + + if partsList.isEmpty || partsList[0].isEmpty { return "Root" } + + let minLen = partsList.map(\.count).min() ?? 0 + var common = 0 + for i in 0.. 0 { + let commonParts = Array(partsList[0].prefix(common)) + let suffix = commonParts.count >= 2 ? Array(commonParts.suffix(2)) : commonParts + return suffix.joined(separator: "/") + .replacingOccurrences(of: "_", with: " ") + .capitalized + } + + let leafDirs = Set(partsList.compactMap(\.last)) + return Array(leafDirs.sorted().prefix(3)).joined(separator: "/") + .replacingOccurrences(of: "_", with: " ") + .capitalized + } +} diff --git a/desktop/macos-claude/Sources/LionsPipeline/TokenBudget.swift b/desktop/macos-claude/Sources/LionsPipeline/TokenBudget.swift new file mode 100644 index 0000000..3d1d4b0 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsPipeline/TokenBudget.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum TokenBudget { + public static let baseMaxTokens = 8192 + public static let tokensPerLine = 12 + public static let maxOutputTokens = 32768 + public static let maxRetries = 2 + + /// Calculate max output tokens based on source line count. + /// Formula: min(8192 + lineCount * 12, 32768) + public static func maxTokensForSource(_ source: String) -> Int { + let lineCount = source.components(separatedBy: "\n").count + return min(baseMaxTokens + lineCount * tokensPerLine, maxOutputTokens) + } +} diff --git a/desktop/macos-claude/Sources/LionsSources/GitDiffProvider.swift b/desktop/macos-claude/Sources/LionsSources/GitDiffProvider.swift new file mode 100644 index 0000000..fdce8a8 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/GitDiffProvider.swift @@ -0,0 +1,191 @@ +import Foundation +import LionsModels + +public enum DiffSource: Sendable { + case githubPR(owner: String, repo: String, number: Int) + case localGit(repoPath: String, range: String) + case unifiedDiff(String) +} + +public protocol GitDiffProviderProtocol: Sendable { + func fetchDiff(source: DiffSource) async throws -> [DiffFile] +} + +public final class GitDiffProvider: GitDiffProviderProtocol, @unchecked Sendable { + private let token: String? + + public init(token: String? = nil) { + self.token = token + } + + public func fetchDiff(source: DiffSource) async throws -> [DiffFile] { + switch source { + case .unifiedDiff(let text): + return parseUnifiedDiff(text) + case .localGit(let repoPath, let range): + let output = try runGitDiff(repoPath: repoPath, range: range) + return parseUnifiedDiff(output) + case .githubPR(let owner, let repo, let number): + let diff = try await fetchGitHubPRDiff(owner: owner, repo: repo, number: number) + return parseUnifiedDiff(diff) + } + } + + // MARK: - Unified Diff Parser + + public static func parseUnifiedDiff(_ text: String) -> [DiffFile] { + GitDiffProvider(token: nil).parseUnifiedDiff(text) + } + + func parseUnifiedDiff(_ text: String) -> [DiffFile] { + let lines = text.components(separatedBy: "\n") + var files: [DiffFile] = [] + var currentPath: String? + var currentStatus: FileStatus = .modified + var currentHunks: [DiffHunk] = [] + var hunkLines: [DiffLine] = [] + var hunkOldStart = 0, hunkOldCount = 0, hunkNewStart = 0, hunkNewCount = 0 + var oldLine = 0, newLine = 0 + var inHunk = false + + func flushHunk() { + if inHunk && !hunkLines.isEmpty { + currentHunks.append(DiffHunk( + oldStart: hunkOldStart, oldCount: hunkOldCount, + newStart: hunkNewStart, newCount: hunkNewCount, + lines: hunkLines + )) + } + hunkLines = [] + inHunk = false + } + + func flushFile() { + flushHunk() + if let path = currentPath { + files.append(DiffFile(path: path, status: currentStatus, hunks: currentHunks)) + } + currentPath = nil + currentHunks = [] + currentStatus = .modified + } + + for line in lines { + if line.hasPrefix("diff --git") { + flushFile() + // Extract path from "diff --git a/path b/path" + let parts = line.split(separator: " ") + if parts.count >= 4 { + currentPath = String(parts[3]).replacingOccurrences(of: "b/", with: "", options: .anchored) + } + continue + } + + if line.hasPrefix("--- ") { + let path = String(line.dropFirst(4)) + if path == "/dev/null" { + currentStatus = .added + } + continue + } + + if line.hasPrefix("+++ ") { + let path = String(line.dropFirst(4)) + if path == "/dev/null" { + currentStatus = .deleted + } else if currentPath == nil { + currentPath = path.replacingOccurrences(of: "b/", with: "", options: .anchored) + } + continue + } + + if line.hasPrefix("rename from ") { + currentStatus = .renamed + continue + } + + if line.hasPrefix("@@ ") { + flushHunk() + inHunk = true + // Parse @@ -old,count +new,count @@ + let hunkHeader = line + if let range = hunkHeader.range(of: #"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"#, + options: .regularExpression) { + let match = String(hunkHeader[range]) + let nums = match.components(separatedBy: CharacterSet.decimalDigits.inverted) + .compactMap { Int($0) } + if nums.count >= 3 { + hunkOldStart = nums[0] + hunkOldCount = nums.count > 1 ? nums[1] : 1 + hunkNewStart = nums[2] + hunkNewCount = nums.count > 3 ? nums[3] : 1 + oldLine = hunkOldStart + newLine = hunkNewStart + } + } + continue + } + + guard inHunk else { continue } + + if line.hasPrefix("+") { + hunkLines.append(DiffLine( + content: String(line.dropFirst()), + type: .addition, + oldLineNumber: nil, + newLineNumber: newLine + )) + newLine += 1 + } else if line.hasPrefix("-") { + hunkLines.append(DiffLine( + content: String(line.dropFirst()), + type: .deletion, + oldLineNumber: oldLine, + newLineNumber: nil + )) + oldLine += 1 + } else if line.hasPrefix(" ") || line.isEmpty { + hunkLines.append(DiffLine( + content: line.isEmpty ? "" : String(line.dropFirst()), + type: .context, + oldLineNumber: oldLine, + newLineNumber: newLine + )) + oldLine += 1 + newLine += 1 + } + } + flushFile() + + return files + } + + // MARK: - Private + + private func runGitDiff(repoPath: String, range: String) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", repoPath, "diff", range] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + + private func fetchGitHubPRDiff(owner: String, repo: String, number: Int) async throws -> String { + let urlStr = "https://api.github.com/repos/\(owner)/\(repo)/pulls/\(number)" + guard let url = URL(string: urlStr) else { + throw SourceError.fetchFailed("Invalid PR URL") + } + var request = URLRequest(url: url) + request.setValue("application/vnd.github.v3.diff", forHTTPHeaderField: "Accept") + if let token = token { + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + } + let (data, _) = try await URLSession.shared.data(for: request) + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/desktop/macos-claude/Sources/LionsSources/GitHubProvider.swift b/desktop/macos-claude/Sources/LionsSources/GitHubProvider.swift new file mode 100644 index 0000000..07cc498 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/GitHubProvider.swift @@ -0,0 +1,115 @@ +import Foundation +import LionsModels + +public final class GitHubProvider: SourceProvider, @unchecked Sendable { + private let token: String? + private let session: URLSession + + public init(token: String? = nil, session: URLSession = .shared) { + self.token = token + self.session = session + } + + public func listFiles(ref: SourceRef) async throws -> [String] { + if ref.provider == "github_gist" { + return try await listGistFiles(gistId: ref.resourceId) + } + + let version = ref.version == "latest" ? "HEAD" : ref.version + let url = URL(string: "https://api.github.com/repos/\(ref.resourceId)/git/trees/\(version)?recursive=1")! + let data = try await fetchAPI(url: url) + + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tree = dict["tree"] as? [[String: Any]] else { + throw SourceError.fetchFailed("Failed to parse tree response") + } + + return tree.compactMap { node in + guard let type = node["type"] as? String, type == "blob", + let path = node["path"] as? String, + LanguageDetection.isSupported(filePath: path) else { return nil } + return path + }.sorted() + } + + public func fetchFile(ref: SourceRef) async throws -> String { + if ref.provider == "github_gist" { + return try await fetchGistFile(gistId: ref.resourceId, filename: ref.path) + } + + let version = ref.version == "latest" ? "HEAD" : ref.version + let urlStr = "https://raw.githubusercontent.com/\(ref.resourceId)/\(version)/\(ref.path)" + guard let url = URL(string: urlStr) else { + throw SourceError.fetchFailed("Invalid URL: \(urlStr)") + } + + var request = URLRequest(url: url) + if let token = token { + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw SourceError.fetchFailed("HTTP error fetching \(ref.path)") + } + + guard let content = String(data: data, encoding: .utf8) else { + throw SourceError.fetchFailed("Non-UTF-8 content in \(ref.path)") + } + return content + } + + public func resolveVersion(resourceId: String, version: String) async throws -> String { + if version != "latest" { return version } + + let url = URL(string: "https://api.github.com/repos/\(resourceId)/commits/HEAD")! + let data = try await fetchAPI(url: url) + + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sha = dict["sha"] as? String else { + throw SourceError.fetchFailed("Failed to resolve HEAD for \(resourceId)") + } + return sha + } + + // MARK: - Private + + private func fetchAPI(url: URL) async throws -> Data { + var request = URLRequest(url: url) + request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") + if let token = token { + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw SourceError.fetchFailed("GitHub API returned \(statusCode) for \(url)") + } + return data + } + + private func listGistFiles(gistId: String) async throws -> [String] { + let url = URL(string: "https://api.github.com/gists/\(gistId)")! + let data = try await fetchAPI(url: url) + + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let files = dict["files"] as? [String: Any] else { + throw SourceError.fetchFailed("Failed to parse gist response") + } + return Array(files.keys).sorted() + } + + private func fetchGistFile(gistId: String, filename: String) async throws -> String { + let url = URL(string: "https://api.github.com/gists/\(gistId)")! + let data = try await fetchAPI(url: url) + + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let files = dict["files"] as? [String: Any], + let file = files[filename] as? [String: Any], + let content = file["content"] as? String else { + throw SourceError.fetchFailed("File \(filename) not found in gist \(gistId)") + } + return content + } +} diff --git a/desktop/macos-claude/Sources/LionsSources/LanguageDetection.swift b/desktop/macos-claude/Sources/LionsSources/LanguageDetection.swift new file mode 100644 index 0000000..c794265 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/LanguageDetection.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum LanguageDetection { + private static let extToLang: [String: String] = [ + ".py": "python", ".c": "c", ".h": "c", ".go": "go", + ".rs": "rust", ".ts": "typescript", ".tsx": "tsx", + ".js": "javascript", ".jsx": "javascript", + ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", + ".hpp": "cpp", ".hh": "cpp", ".java": "java", + ] + + public static func detect(filePath: String) -> String? { + let ext = "." + (filePath.split(separator: ".").last.map(String.init) ?? "") + return extToLang[ext.lowercased()] + } + + public static func isSupported(filePath: String) -> Bool { + detect(filePath: filePath) != nil + } + + public static var supportedExtensions: [String] { + Array(extToLang.keys.sorted()) + } +} diff --git a/desktop/macos-claude/Sources/LionsSources/LocalFileProvider.swift b/desktop/macos-claude/Sources/LionsSources/LocalFileProvider.swift new file mode 100644 index 0000000..971e087 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/LocalFileProvider.swift @@ -0,0 +1,68 @@ +import Foundation +import LionsModels + +public final class LocalFileProvider: SourceProvider, @unchecked Sendable { + public init() {} + + public func listFiles(ref: SourceRef) async throws -> [String] { + let basePath = ref.resourceId + let baseURL = URL(fileURLWithPath: basePath) + let fm = FileManager.default + + guard fm.fileExists(atPath: basePath) else { + throw SourceError.pathNotFound(basePath) + } + + var files: [String] = [] + let enumerator = fm.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let url = enumerator?.nextObject() as? URL { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey]), + values.isRegularFile == true else { continue } + + // Use standardized paths so symlinks / trailing slashes don't break stripping + let standardizedBase = baseURL.standardizedFileURL.path + let relativePath = url.standardizedFileURL.path + .replacingOccurrences(of: standardizedBase + "/", with: "") + if LanguageDetection.isSupported(filePath: relativePath) { + files.append(relativePath) + } + } + + return files.sorted() + } + + public func fetchFile(ref: SourceRef) async throws -> String { + let fullPath: String + if ref.path.hasPrefix("/") { + fullPath = ref.path + } else { + fullPath = ref.resourceId + "/" + ref.path + } + + guard FileManager.default.fileExists(atPath: fullPath) else { + throw SourceError.pathNotFound(fullPath) + } + + return try String(contentsOfFile: fullPath, encoding: .utf8) + } + + public func resolveVersion(resourceId: String, version: String) async throws -> String { + "local" + } +} + +public enum SourceError: Error, LocalizedError { + case pathNotFound(String) + case fetchFailed(String) + case unsupportedProvider(String) + + public var errorDescription: String? { + switch self { + case .pathNotFound(let path): return "Path not found: \(path)" + case .fetchFailed(let msg): return "Fetch failed: \(msg)" + case .unsupportedProvider(let p): return "Unsupported provider: \(p)" + } + } +} diff --git a/desktop/macos-claude/Sources/LionsSources/SourceProvider.swift b/desktop/macos-claude/Sources/LionsSources/SourceProvider.swift new file mode 100644 index 0000000..a5f35d1 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/SourceProvider.swift @@ -0,0 +1,8 @@ +import Foundation +import LionsModels + +public protocol SourceProvider: Sendable { + func listFiles(ref: SourceRef) async throws -> [String] + func fetchFile(ref: SourceRef) async throws -> String + func resolveVersion(resourceId: String, version: String) async throws -> String +} diff --git a/desktop/macos-claude/Sources/LionsSources/URLParsing.swift b/desktop/macos-claude/Sources/LionsSources/URLParsing.swift new file mode 100644 index 0000000..f1a5067 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsSources/URLParsing.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum GitHubURL: Equatable, Sendable { + case repo(owner: String, repo: String, ref: String?, path: String?) + case gist(id: String, filename: String?) + case pullRequest(owner: String, repo: String, number: Int) +} + +public func parseGitHubURL(_ urlString: String) -> GitHubURL? { + guard let url = URL(string: urlString), + let host = url.host else { return nil } + + let pathComponents = url.pathComponents.filter { $0 != "/" } + + // Gist + if host == "gist.github.com" { + if pathComponents.count >= 2 { + return .gist(id: pathComponents[1], filename: nil) + } else if pathComponents.count == 1 { + return .gist(id: pathComponents[0], filename: nil) + } + return nil + } + + guard host == "github.com" || host == "www.github.com" else { return nil } + guard pathComponents.count >= 2 else { return nil } + + let owner = pathComponents[0] + let repo = pathComponents[1] + + // Pull request: /owner/repo/pull/123 + if pathComponents.count >= 4 && pathComponents[2] == "pull", + let number = Int(pathComponents[3]) { + return .pullRequest(owner: owner, repo: repo, number: number) + } + + // Blob or tree: /owner/repo/blob/ref/path or /owner/repo/tree/ref/path + if pathComponents.count >= 4 && (pathComponents[2] == "blob" || pathComponents[2] == "tree") { + let ref = pathComponents[3] + let path = pathComponents.count > 4 ? pathComponents[4...].joined(separator: "/") : nil + return .repo(owner: owner, repo: repo, ref: ref, path: path) + } + + // Plain repo: /owner/repo + return .repo(owner: owner, repo: repo, ref: nil, path: nil) +} diff --git a/desktop/macos-claude/Sources/LionsStorage/GRDBStorage.swift b/desktop/macos-claude/Sources/LionsStorage/GRDBStorage.swift new file mode 100644 index 0000000..1fe561d --- /dev/null +++ b/desktop/macos-claude/Sources/LionsStorage/GRDBStorage.swift @@ -0,0 +1,313 @@ +import Foundation +import GRDB +import LionsModels + +public final class GRDBStorage: StorageProtocol, @unchecked Sendable { + private let dbQueue: DatabaseQueue + + public init(path: String? = nil) throws { + let dbPath: String + if let path = path { + dbPath = path + } else { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let lionsDir = appSupport.appendingPathComponent("Lions") + try FileManager.default.createDirectory(at: lionsDir, withIntermediateDirectories: true) + dbPath = lionsDir.appendingPathComponent("lions.db").path + } + + dbQueue = try DatabaseQueue(path: dbPath) + try dbQueue.write { db in + try db.execute(sql: "PRAGMA journal_mode=WAL") + } + + var migrator = DatabaseMigrator() + Migrations.register(in: &migrator) + try migrator.migrate(dbQueue) + } + + /// Initialize with an in-memory database (for testing). + public init(inMemory: Bool) throws { + dbQueue = try DatabaseQueue() + var migrator = DatabaseMigrator() + Migrations.register(in: &migrator) + try migrator.migrate(dbQueue) + } + + // MARK: - Annotations + + public func storeAnnotation(provider: String, resourceId: String, version: String, + path: String, contentHash: String, annotationJSON: String, + sourceText: String?) async throws { + try await dbQueue.write { db in + try db.execute( + sql: """ + INSERT OR REPLACE INTO annotations + (provider, resource_id, version, path, content_hash, annotation_json, source_text) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [provider, resourceId, version, path, contentHash, annotationJSON, sourceText] + ) + } + } + + public func getAnnotation(provider: String, resourceId: String, version: String, + path: String) async throws -> StoredAnnotation? { + try await dbQueue.read { db in + let row = try Row.fetchOne(db, sql: """ + SELECT annotation_json, content_hash, source_text, created_at FROM annotations + WHERE provider=? AND resource_id=? AND version=? AND path=? + """, arguments: [provider, resourceId, version, path]) + + guard let row = row else { return nil } + return StoredAnnotation( + annotationJSON: row["annotation_json"], + contentHash: row["content_hash"], + sourceText: row["source_text"], + createdAt: row["created_at"] + ) + } + } + + public func listAnnotations() async throws -> [AnnotationEntry] { + try await dbQueue.read { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT provider, resource_id, version, path, content_hash, created_at FROM annotations + """) + return rows.map { row in + AnnotationEntry( + provider: row["provider"], + resourceId: row["resource_id"], + version: row["version"], + path: row["path"], + contentHash: row["content_hash"], + createdAt: row["created_at"] + ) + } + } + } + + // MARK: - Repo Analysis + + public func storeRepoAnalysis(provider: String, resourceId: String, version: String, + analysisJSON: String, displayName: String?) async throws { + try await dbQueue.write { db in + try db.execute( + sql: """ + INSERT OR REPLACE INTO repo_analysis + (provider, resource_id, version, analysis_json, display_name) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [provider, resourceId, version, analysisJSON, displayName] + ) + } + } + + public func getRepoAnalysis(provider: String, resourceId: String, + version: String) async throws -> StoredRepoAnalysis? { + try await dbQueue.read { db in + let row: Row? + if version == "latest" { + row = try Row.fetchOne(db, sql: """ + SELECT analysis_json, summary_json, reading_guide_json, display_name, created_at + FROM repo_analysis WHERE provider=? AND resource_id=? + ORDER BY created_at DESC LIMIT 1 + """, arguments: [provider, resourceId]) + } else { + row = try Row.fetchOne(db, sql: """ + SELECT analysis_json, summary_json, reading_guide_json, display_name, created_at + FROM repo_analysis WHERE provider=? AND resource_id=? AND version=? + """, arguments: [provider, resourceId, version]) + } + guard let row = row else { return nil } + return StoredRepoAnalysis( + analysisJSON: row["analysis_json"], + summaryJSON: row["summary_json"], + readingGuideJSON: row["reading_guide_json"], + displayName: row["display_name"], + createdAt: row["created_at"] + ) + } + } + + public func storeRepoSummary(provider: String, resourceId: String, version: String, + summaryJSON: String) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE repo_analysis SET summary_json = ? WHERE provider = ? AND resource_id = ? AND version = ?", + arguments: [summaryJSON, provider, resourceId, version] + ) + } + } + + public func storeReadingGuide(provider: String, resourceId: String, version: String, + guideJSON: String) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE repo_analysis SET reading_guide_json = ? WHERE provider = ? AND resource_id = ? AND version = ?", + arguments: [guideJSON, provider, resourceId, version] + ) + } + } + + public func getReadingGuide(provider: String, resourceId: String, + version: String) async throws -> String? { + try await dbQueue.read { db in + let row: Row? + if version == "latest" { + row = try Row.fetchOne(db, sql: """ + SELECT reading_guide_json FROM repo_analysis + WHERE provider=? AND resource_id=? ORDER BY created_at DESC LIMIT 1 + """, arguments: [provider, resourceId]) + } else { + row = try Row.fetchOne(db, sql: """ + SELECT reading_guide_json FROM repo_analysis + WHERE provider=? AND resource_id=? AND version=? + """, arguments: [provider, resourceId, version]) + } + return row?["reading_guide_json"] + } + } + + public func updateDisplayName(provider: String, resourceId: String, version: String, + displayName: String) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE repo_analysis SET display_name = ? WHERE provider = ? AND resource_id = ? AND version = ?", + arguments: [displayName, provider, resourceId, version] + ) + } + } + + public func listRepos() async throws -> [RepoEntry] { + try await dbQueue.read { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT + a.provider, + a.resource_id, + a.version, + COUNT(DISTINCT a.path) as file_count, + MAX(a.created_at) as last_updated, + r.analysis_json IS NOT NULL as has_analysis, + r.summary_json IS NOT NULL as has_summary, + r.reading_guide_json IS NOT NULL as has_guide, + r.summary_json, + r.display_name + FROM annotations a + LEFT JOIN repo_analysis r + ON a.provider = r.provider AND a.resource_id = r.resource_id AND a.version = r.version + GROUP BY a.provider, a.resource_id, a.version + ORDER BY last_updated DESC + """) + + return rows.map { row in + let summaryText: String? = { + guard let json: String = row["summary_json"] else { return nil } + guard let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return dict["summary"] as? String + }() + + return RepoEntry( + provider: row["provider"], + resourceId: row["resource_id"], + version: row["version"], + fileCount: row["file_count"], + lastUpdated: row["last_updated"], + hasAnalysis: row["has_analysis"] as? Bool ?? false, + hasSummary: row["has_summary"] as? Bool ?? false, + hasGuide: row["has_guide"] as? Bool ?? false, + summaryText: summaryText, + primaryLanguage: nil, + displayName: row["display_name"] + ) + } + } + } + + // MARK: - Pipeline Costs + + public func storePipelineCost(_ record: PipelineCostRecord) async throws { + try await dbQueue.write { db in + try db.execute( + sql: """ + INSERT INTO pipeline_costs + (provider, resource_id, version, path, stage, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [record.provider, record.resourceId, record.version, record.path, + record.stage, record.model, record.inputTokens, record.outputTokens, + record.cacheCreationTokens, record.cacheReadTokens] + ) + } + } + + public func getPipelineCosts(provider: String?, resourceId: String?) async throws -> [PipelineCostRecord] { + try await dbQueue.read { db in + let rows: [Row] + if let provider = provider, let resourceId = resourceId { + rows = try Row.fetchAll(db, sql: """ + SELECT * FROM pipeline_costs WHERE provider=? AND resource_id=? ORDER BY created_at DESC + """, arguments: [provider, resourceId]) + } else { + rows = try Row.fetchAll(db, sql: "SELECT * FROM pipeline_costs ORDER BY created_at DESC") + } + return rows.map { row in + PipelineCostRecord( + provider: row["provider"], resourceId: row["resource_id"], + version: row["version"], path: row["path"], + stage: row["stage"], model: row["model"], + inputTokens: row["input_tokens"], outputTokens: row["output_tokens"], + cacheCreationTokens: row["cache_creation_tokens"], + cacheReadTokens: row["cache_read_tokens"], + createdAt: row["created_at"] + ) + } + } + } + + public func getPipelineCostSummary(provider: String?, resourceId: String?) async throws -> CostSummary { + try await dbQueue.read { db in + var where_ = "" + var args: [any DatabaseValueConvertible] = [] + if let provider = provider, let resourceId = resourceId { + where_ = "WHERE provider=? AND resource_id=?" + args = [provider, resourceId] + } + + let totalRow = try Row.fetchOne(db, sql: """ + SELECT COUNT(*) as total_calls, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + MIN(created_at) as first_call, + MAX(created_at) as last_call + FROM pipeline_costs \(where_) + """, arguments: StatementArguments(args))! + + let stageRows = try Row.fetchAll(db, sql: """ + SELECT stage, COUNT(*) as calls, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens + FROM pipeline_costs \(where_) GROUP BY stage + """, arguments: StatementArguments(args)) + + let byStage = stageRows.map { row in + StageCost( + stage: row["stage"], + calls: row["calls"], + inputTokens: row["input_tokens"], + outputTokens: row["output_tokens"] + ) + } + + return CostSummary( + totalCalls: totalRow["total_calls"], + totalInputTokens: totalRow["total_input_tokens"], + totalOutputTokens: totalRow["total_output_tokens"], + firstCall: totalRow["first_call"], + lastCall: totalRow["last_call"], + byStage: byStage + ) + } + } +} diff --git a/desktop/macos-claude/Sources/LionsStorage/Migrations.swift b/desktop/macos-claude/Sources/LionsStorage/Migrations.swift new file mode 100644 index 0000000..6d5e018 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsStorage/Migrations.swift @@ -0,0 +1,53 @@ +import Foundation +import GRDB + +public enum Migrations { + public static func register(in migrator: inout DatabaseMigrator) { + migrator.registerMigration("v1") { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS annotations ( + provider TEXT NOT NULL, + resource_id TEXT NOT NULL, + version TEXT NOT NULL, + path TEXT NOT NULL, + content_hash TEXT NOT NULL, + annotation_json TEXT NOT NULL, + source_text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, resource_id, version, path) + ) + """) + + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS pipeline_costs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + resource_id TEXT NOT NULL, + version TEXT NOT NULL, + path TEXT, + stage TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS repo_analysis ( + provider TEXT NOT NULL, + resource_id TEXT NOT NULL, + version TEXT NOT NULL, + analysis_json TEXT NOT NULL, + summary_json TEXT, + reading_guide_json TEXT, + display_name TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, resource_id, version) + ) + """) + } + } +} diff --git a/desktop/macos-claude/Sources/LionsStorage/StorageError.swift b/desktop/macos-claude/Sources/LionsStorage/StorageError.swift new file mode 100644 index 0000000..f76be71 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsStorage/StorageError.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum StorageError: Error, LocalizedError { + case databaseError(String) + case notFound + case migrationFailed(String) + + public var errorDescription: String? { + switch self { + case .databaseError(let msg): return "Database error: \(msg)" + case .notFound: return "Record not found" + case .migrationFailed(let msg): return "Migration failed: \(msg)" + } + } +} diff --git a/desktop/macos-claude/Sources/LionsStorage/StorageProtocol.swift b/desktop/macos-claude/Sources/LionsStorage/StorageProtocol.swift new file mode 100644 index 0000000..acf0e02 --- /dev/null +++ b/desktop/macos-claude/Sources/LionsStorage/StorageProtocol.swift @@ -0,0 +1,130 @@ +import Foundation +import LionsModels + +// MARK: - Helper Structs + +public struct StoredAnnotation: Sendable { + public let annotationJSON: String + public let contentHash: String + public let sourceText: String? + public let createdAt: String +} + +public struct AnnotationEntry: Sendable { + public let provider: String + public let resourceId: String + public let version: String + public let path: String + public let contentHash: String + public let createdAt: String +} + +public struct StoredRepoAnalysis: Sendable { + public let analysisJSON: String + public let summaryJSON: String? + public let readingGuideJSON: String? + public let displayName: String? + public let createdAt: String +} + +public struct RepoEntry: Sendable, Identifiable { + public var id: String { "\(provider)/\(resourceId)/\(version)" } + public let provider: String + public let resourceId: String + public let version: String + public let fileCount: Int + public let lastUpdated: String + public let hasAnalysis: Bool + public let hasSummary: Bool + public let hasGuide: Bool + public let summaryText: String? + public let primaryLanguage: String? + public let displayName: String? +} + +public struct PipelineCostRecord: Sendable { + public let provider: String + public let resourceId: String + public let version: String + public let path: String? + public let stage: String + public let model: String + public let inputTokens: Int + public let outputTokens: Int + public let cacheCreationTokens: Int + public let cacheReadTokens: Int + public let createdAt: String? + + public init(provider: String, resourceId: String, version: String, + path: String?, stage: String, model: String, + inputTokens: Int, outputTokens: Int, + cacheCreationTokens: Int = 0, cacheReadTokens: Int = 0, + createdAt: String? = nil) { + self.provider = provider + self.resourceId = resourceId + self.version = version + self.path = path + self.stage = stage + self.model = model + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheCreationTokens = cacheCreationTokens + self.cacheReadTokens = cacheReadTokens + self.createdAt = createdAt + } +} + +public struct CostSummary: Sendable { + public let totalCalls: Int + public let totalInputTokens: Int + public let totalOutputTokens: Int + public let firstCall: String? + public let lastCall: String? + public let byStage: [StageCost] +} + +public struct StageCost: Sendable { + public let stage: String + public let calls: Int + public let inputTokens: Int + public let outputTokens: Int +} + +// MARK: - Protocol + +public protocol StorageProtocol: Sendable { + func storeAnnotation(provider: String, resourceId: String, version: String, + path: String, contentHash: String, annotationJSON: String, + sourceText: String?) async throws + + func getAnnotation(provider: String, resourceId: String, version: String, + path: String) async throws -> StoredAnnotation? + + func listAnnotations() async throws -> [AnnotationEntry] + + func storeRepoAnalysis(provider: String, resourceId: String, version: String, + analysisJSON: String, displayName: String?) async throws + + func getRepoAnalysis(provider: String, resourceId: String, + version: String) async throws -> StoredRepoAnalysis? + + func storeRepoSummary(provider: String, resourceId: String, version: String, + summaryJSON: String) async throws + + func storeReadingGuide(provider: String, resourceId: String, version: String, + guideJSON: String) async throws + + func getReadingGuide(provider: String, resourceId: String, + version: String) async throws -> String? + + func listRepos() async throws -> [RepoEntry] + + func updateDisplayName(provider: String, resourceId: String, version: String, + displayName: String) async throws + + func storePipelineCost(_ record: PipelineCostRecord) async throws + + func getPipelineCosts(provider: String?, resourceId: String?) async throws -> [PipelineCostRecord] + + func getPipelineCostSummary(provider: String?, resourceId: String?) async throws -> CostSummary +} diff --git a/desktop/macos-claude/Tests/LionsAnalysisTests/AnalysisTests.swift b/desktop/macos-claude/Tests/LionsAnalysisTests/AnalysisTests.swift new file mode 100644 index 0000000..16f2d71 --- /dev/null +++ b/desktop/macos-claude/Tests/LionsAnalysisTests/AnalysisTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import LionsAnalysis +@testable import LionsModels + +// MARK: - Symbol Table Tests + +final class SymbolTableTests: XCTestCase { + + func testBuildAndLookup() { + let defn = SymbolDefinition( + name: "processData", kind: "function", + startLine: 10, endLine: 25, visibility: "exported" + ) + let atoms: [String: ExtendedFileAtoms] = [ + "src/utils.py": ExtendedFileAtoms( + filePath: "src/utils.py", language: "python", + atoms: [], definitions: [defn] + ) + ] + + let table = buildSymbolTable(allAtoms: atoms) + XCTAssertNotNil(table["processData"]) + XCTAssertEqual(table["processData"]?.count, 1) + XCTAssertEqual(table["processData"]?[0].0, "src/utils.py") + } + + func testLookupMissing() { + let atoms: [String: ExtendedFileAtoms] = [ + "main.py": ExtendedFileAtoms( + filePath: "main.py", language: "python", + atoms: [], definitions: [ + SymbolDefinition(name: "main", kind: "function", + startLine: 1, endLine: 5, visibility: "exported") + ] + ) + ] + + let table = buildSymbolTable(allAtoms: atoms) + XCTAssertNil(table["nonExistentSymbol"]) + } + + func testMultipleDefinitions() { + let atoms: [String: ExtendedFileAtoms] = [ + "a.py": ExtendedFileAtoms(filePath: "a.py", language: "python", atoms: [], definitions: [ + SymbolDefinition(name: "Config", kind: "class", startLine: 1, endLine: 20, visibility: "exported") + ]), + "b.py": ExtendedFileAtoms(filePath: "b.py", language: "python", atoms: [], definitions: [ + SymbolDefinition(name: "Config", kind: "class", startLine: 5, endLine: 30, visibility: "exported") + ]), + ] + + let table = buildSymbolTable(allAtoms: atoms) + XCTAssertEqual(table["Config"]?.count, 2) + } +} + +// MARK: - PageRank Tests + +final class PageRankTests: XCTestCase { + + func testSimpleThreeNodeGraph() { + let files: Set = ["a.py", "b.py", "c.py"] + let edges: [(String, String)] = [("a.py", "b.py"), ("b.py", "c.py"), ("c.py", "a.py")] + + let scores = computePageRank(files: files, edges: edges) + XCTAssertEqual(scores.count, 3) + for file in files { + XCTAssertGreaterThan(scores[file] ?? 0, 0) + XCTAssertLessThanOrEqual(scores[file] ?? 0, 1.0) + } + XCTAssertEqual(scores.values.max() ?? 0.0, 1.0, accuracy: 0.001) + } + + func testDisconnectedGraph() { + let files: Set = ["a.py", "b.py", "c.py"] + let edges: [(String, String)] = [("a.py", "b.py")] + + let scores = computePageRank(files: files, edges: edges) + XCTAssertEqual(scores.count, 3) + XCTAssertGreaterThan(scores["b.py"] ?? 0, scores["c.py"] ?? 0) + } + + func testEmptyGraph() { + let scores = computePageRank(files: Set(), edges: []) + XCTAssertTrue(scores.isEmpty) + } + + func testSingleNode() { + let scores = computePageRank(files: ["only.py"], edges: []) + XCTAssertEqual(scores["only.py"] ?? 0, 1.0, accuracy: 0.001) + } + + func testLinearChain() { + let files: Set = ["a.py", "b.py", "c.py", "d.py"] + let edges: [(String, String)] = [("a.py", "b.py"), ("b.py", "c.py"), ("c.py", "d.py")] + + let scores = computePageRank(files: files, edges: edges) + XCTAssertGreaterThan(scores["d.py"] ?? 0, scores["a.py"] ?? 0) + } +} + +// MARK: - Reading Order Tests + +final class ReadingOrderTests: XCTestCase { + + func testSimpleDAG() { + let files: Set = ["a.py", "b.py", "c.py"] + let importEdges: [(String, String)] = [("b.py", "a.py"), ("c.py", "b.py")] + let pr: [String: Double] = ["a.py": 1.0, "b.py": 0.5, "c.py": 0.3] + + let order = computeReadingOrder(files: files, importEdges: importEdges, pagerankScores: pr) + XCTAssertEqual(order.count, 3) + + let indexA = order.firstIndex(of: "a.py")! + let indexB = order.firstIndex(of: "b.py")! + let indexC = order.firstIndex(of: "c.py")! + XCTAssertLessThan(indexA, indexB) + XCTAssertLessThan(indexB, indexC) + } + + func testGraphWithCycle() { + let files: Set = ["a.py", "b.py", "c.py"] + let importEdges: [(String, String)] = [("a.py", "b.py"), ("b.py", "a.py"), ("c.py", "a.py")] + let pr: [String: Double] = ["a.py": 0.8, "b.py": 0.6, "c.py": 0.3] + + let order = computeReadingOrder(files: files, importEdges: importEdges, pagerankScores: pr) + XCTAssertEqual(order.count, 3) + XCTAssertEqual(Set(order), files) + } + + func testDisconnectedFiles() { + let files: Set = ["a.py", "b.py", "island.py"] + let importEdges: [(String, String)] = [("b.py", "a.py")] + let pr: [String: Double] = ["a.py": 0.5, "b.py": 0.3, "island.py": 0.9] + + let order = computeReadingOrder(files: files, importEdges: importEdges, pagerankScores: pr) + XCTAssertEqual(order.count, 3) + XCTAssertEqual(Set(order), files) + } +} + +// MARK: - Import Graph Tests + +final class ImportGraphTests: XCTestCase { + + func testResolvePythonImport() { + let atoms: [String: ExtendedFileAtoms] = [ + "src/utils.py": ExtendedFileAtoms(filePath: "src/utils.py", language: "python", atoms: []), + "src/main.py": ExtendedFileAtoms( + filePath: "src/main.py", language: "python", atoms: [], + imports: [ImportTarget(module: ".utils", line: 1)] + ), + ] + + let edges = resolveImports(allAtoms: atoms) + let found = edges.contains { $0.0 == "src/main.py" && $0.1 == "src/utils.py" } + XCTAssertTrue(found) + } + + func testResolveTypeScriptImport() { + let atoms: [String: ExtendedFileAtoms] = [ + "src/helper.ts": ExtendedFileAtoms(filePath: "src/helper.ts", language: "typescript", atoms: []), + "src/app.ts": ExtendedFileAtoms( + filePath: "src/app.ts", language: "typescript", atoms: [], + imports: [ImportTarget(module: "./helper", line: 1)] + ), + ] + + let edges = resolveImports(allAtoms: atoms) + let found = edges.contains { $0.0 == "src/app.ts" && $0.1 == "src/helper.ts" } + XCTAssertTrue(found, "Should resolve relative TS import with path normalization") + } + + func testNonRelativeTSImportNotResolved() { + let atoms: [String: ExtendedFileAtoms] = [ + "src/app.ts": ExtendedFileAtoms( + filePath: "src/app.ts", language: "typescript", atoms: [], + imports: [ImportTarget(module: "react", line: 1)] + ), + ] + + let edges = resolveImports(allAtoms: atoms) + XCTAssertTrue(edges.isEmpty) + } +} + +// MARK: - Repo Analyzer Tests + +final class RepoAnalyzerTests: XCTestCase { + + func testAnalyzeEmptyRepo() { + let analyzer = RepoAnalyzer() + let result = analyzer.analyzeRepo( + allAtoms: [:], provider: "local", resourceId: "test", version: "v1" + ) + XCTAssertTrue(result.files.isEmpty) + XCTAssertTrue(result.crossReferences.isEmpty) + XCTAssertTrue(result.readingOrder.isEmpty) + } + + func testAnalyzeSingleFile() { + let analyzer = RepoAnalyzer() + let atoms: [String: ExtendedFileAtoms] = [ + "main.py": ExtendedFileAtoms( + filePath: "main.py", language: "python", + atoms: [StructuralAtom(name: "main", kind: "function", startLine: 1, endLine: 10, source: "def main(): pass")], + definitions: [SymbolDefinition(name: "main", kind: "function", startLine: 1, endLine: 10, visibility: "exported")] + ) + ] + + let result = analyzer.analyzeRepo( + allAtoms: atoms, provider: "local", resourceId: "test", version: "v1" + ) + XCTAssertEqual(result.files.count, 1) + XCTAssertEqual(result.files[0].filePath, "main.py") + XCTAssertEqual(result.files[0].definitionsCount, 1) + } +} diff --git a/desktop/macos-claude/Tests/LionsLLMTests/LLMTests.swift b/desktop/macos-claude/Tests/LionsLLMTests/LLMTests.swift new file mode 100644 index 0000000..ccbbebd --- /dev/null +++ b/desktop/macos-claude/Tests/LionsLLMTests/LLMTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import LionsLLM +@testable import LionsModels + +// MARK: - SSE Parser Tests + +final class SSEParserTests: XCTestCase { + + func testParseContentBlockDelta() { + var parser = SSEParser() + let input = """ + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + + """.data(using: .utf8)! + + let events = parser.parse(data: input) + XCTAssertEqual(events.count, 1) + if case .contentBlockDelta(let text) = events[0] { + XCTAssertEqual(text, "Hello") + } else { + XCTFail("Expected contentBlockDelta") + } + } + + func testParseMessageStop() { + var parser = SSEParser() + let input = """ + event: message_stop + data: {"type":"message_stop"} + + """.data(using: .utf8)! + + let events = parser.parse(data: input) + XCTAssertEqual(events.count, 1) + if case .messageStop = events[0] { + // pass + } else { + XCTFail("Expected messageStop") + } + } + + func testParseMessageStart() { + var parser = SSEParser() + let input = """ + event: message_start + data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant"}} + + """.data(using: .utf8)! + + let events = parser.parse(data: input) + XCTAssertEqual(events.count, 1) + if case .messageStart(let id) = events[0] { + XCTAssertEqual(id, "msg_123") + } else { + XCTFail("Expected messageStart") + } + } + + func testParseMultipleEvents() { + var parser = SSEParser() + let input = """ + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" there"}} + + event: message_stop + data: {"type":"message_stop"} + + """.data(using: .utf8)! + + let events = parser.parse(data: input) + XCTAssertEqual(events.count, 3) + } + + func testParseErrorEvent() { + var parser = SSEParser() + let input = """ + event: error + data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}} + + """.data(using: .utf8)! + + let events = parser.parse(data: input) + XCTAssertEqual(events.count, 1) + if case .error(let msg) = events[0] { + XCTAssertEqual(msg, "Overloaded") + } else { + XCTFail("Expected error event") + } + } +} + +// MARK: - JSON Repair Tests + +final class JSONRepairTests: XCTestCase { + + func testValidJSONPassesThrough() throws { + let json = #"{"key": "value", "num": 42}"# + let repaired = JSONRepair.repair(json) + // Should still be valid JSON + let data = repaired.data(using: .utf8)! + let _ = try JSONSerialization.jsonObject(with: data) + } + + func testRemoveTrailingCommas() { + let input = #"{"a": 1, "b": 2,}"# + let repaired = JSONRepair.repair(input) + XCTAssertFalse(repaired.contains(",}")) + } + + func testRemoveTrailingCommaInArray() { + let input = #"[1, 2, 3,]"# + let repaired = JSONRepair.repair(input) + XCTAssertFalse(repaired.contains(",]")) + } + + func testRemoveComments() { + let input = """ + { + "key": "value" // this is a comment + } + """ + let repaired = JSONRepair.repair(input) + XCTAssertFalse(repaired.contains("//")) + } + + func testExtractFromMarkdownFences() { + let input = """ + ```json + {"key": "value"} + ``` + """ + let repaired = JSONRepair.repair(input) + XCTAssertTrue(repaired.contains(#""key""#)) + XCTAssertFalse(repaired.contains("```")) + } + + func testParseJSONValid() throws { + let json = #"{"key": "value"}"# + let result = try JSONRepair.parseJSON(json) as? [String: Any] + XCTAssertEqual(result?["key"] as? String, "value") + } + + func testParseJSONWithRepair() throws { + let json = #"{"key": "value",}"# + let result = try JSONRepair.parseJSON(json) as? [String: Any] + XCTAssertEqual(result?["key"] as? String, "value") + } + + func testParseJSONFromFences() throws { + let json = """ + ```json + {"key": 42} + ``` + """ + let result = try JSONRepair.parseJSON(json) as? [String: Any] + XCTAssertEqual(result?["key"] as? Int, 42) + } +} + +// MARK: - LLM Types Tests + +final class LLMTypesTests: XCTestCase { + + func testLLMMessageCreation() { + let msg = LLMMessage(role: "user", content: "Hello") + XCTAssertEqual(msg.role, "user") + XCTAssertEqual(msg.content, "Hello") + } + + func testLLMErrorCases() { + let err1 = LLMError.invalidAPIKey + let err2 = LLMError.requestFailed(statusCode: 429, body: "rate limited") + let err3 = LLMError.decodingError("bad json") + let err4 = LLMError.streamError("connection lost") + + XCTAssertNotNil(err1) + XCTAssertNotNil(err2) + XCTAssertNotNil(err3) + XCTAssertNotNil(err4) + } +} diff --git a/desktop/macos-claude/Tests/LionsModelsTests/ModelsTests.swift b/desktop/macos-claude/Tests/LionsModelsTests/ModelsTests.swift new file mode 100644 index 0000000..94b98d1 --- /dev/null +++ b/desktop/macos-claude/Tests/LionsModelsTests/ModelsTests.swift @@ -0,0 +1,437 @@ +import XCTest +@testable import LionsModels + +final class ModelsTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + return d + }() + private let encoder: JSONEncoder = { + let e = JSONEncoder() + return e + }() + + // MARK: - SemanticGroup + + func testSemanticGroupRoundTrip() throws { + let json = """ + { + "id": "g1", + "name": "Imports", + "description": "Module imports", + "start_line": 1, + "end_line": 5, + "children": ["g2"], + "quick": "Sets up dependencies", + "deep": "Detailed explanation of imports" + } + """.data(using: .utf8)! + + let group = try decoder.decode(SemanticGroup.self, from: json) + XCTAssertEqual(group.id, "g1") + XCTAssertEqual(group.name, "Imports") + XCTAssertEqual(group.startLine, 1) + XCTAssertEqual(group.endLine, 5) + XCTAssertEqual(group.children, ["g2"]) + XCTAssertEqual(group.quick, "Sets up dependencies") + + let reencoded = try encoder.encode(group) + let decoded = try decoder.decode(SemanticGroup.self, from: reencoded) + XCTAssertEqual(decoded, group) + } + + func testSemanticGroupDefaultChildren() throws { + let json = """ + { + "id": "g1", "name": "A", "description": "B", + "start_line": 1, "end_line": 2, + "children": [], "quick": "Q", "deep": "D" + } + """.data(using: .utf8)! + + let group = try decoder.decode(SemanticGroup.self, from: json) + XCTAssertEqual(group.children, []) + } + + // MARK: - MarginAnnotation + + func testMarginAnnotationRoundTrip() throws { + let json = """ + {"line": 42, "kind": "insight", "text": "Non-obvious pattern", "confidence": 0.85} + """.data(using: .utf8)! + + let ann = try decoder.decode(MarginAnnotation.self, from: json) + XCTAssertEqual(ann.line, 42) + XCTAssertEqual(ann.kind, "insight") + XCTAssertEqual(ann.text, "Non-obvious pattern") + XCTAssertEqual(ann.confidence, 0.85) + + let reencoded = try encoder.encode(ann) + let decoded = try decoder.decode(MarginAnnotation.self, from: reencoded) + XCTAssertEqual(decoded, ann) + } + + // MARK: - Reference + + func testReferenceRoundTrip() throws { + let json = """ + {"from_line": 10, "from_symbol": "foo", "to_line": 20, "to_symbol": "bar", "kind": "calls"} + """.data(using: .utf8)! + + let ref = try decoder.decode(Reference.self, from: json) + XCTAssertEqual(ref.fromLine, 10) + XCTAssertEqual(ref.fromSymbol, "foo") + XCTAssertEqual(ref.toLine, 20) + XCTAssertEqual(ref.toSymbol, "bar") + XCTAssertEqual(ref.kind, "calls") + } + + // MARK: - SourceRef + + func testSourceRefRoundTrip() throws { + let json = """ + {"provider": "github_repo", "resource_id": "owner/repo", "version": "abc123", "path": "src/main.py"} + """.data(using: .utf8)! + + let ref = try decoder.decode(SourceRef.self, from: json) + XCTAssertEqual(ref.provider, "github_repo") + XCTAssertEqual(ref.resourceId, "owner/repo") + XCTAssertEqual(ref.version, "abc123") + XCTAssertEqual(ref.path, "src/main.py") + } + + // MARK: - StructuralAtom + + func testStructuralAtomRoundTrip() throws { + let json = """ + { + "name": "MyClass", + "kind": "class", + "start_line": 5, + "end_line": 50, + "source": "class MyClass:\\n pass", + "parent": null, + "children": ["method_a", "method_b"] + } + """.data(using: .utf8)! + + let atom = try decoder.decode(StructuralAtom.self, from: json) + XCTAssertEqual(atom.name, "MyClass") + XCTAssertEqual(atom.kind, "class") + XCTAssertEqual(atom.startLine, 5) + XCTAssertEqual(atom.endLine, 50) + XCTAssertNil(atom.parent) + XCTAssertEqual(atom.children, ["method_a", "method_b"]) + } + + // MARK: - CallSite + + func testCallSiteRoundTrip() throws { + let json = """ + {"caller": "main", "callee": "process", "line": 15, "is_method": true} + """.data(using: .utf8)! + + let call = try decoder.decode(CallSite.self, from: json) + XCTAssertEqual(call.caller, "main") + XCTAssertEqual(call.callee, "process") + XCTAssertEqual(call.line, 15) + XCTAssertTrue(call.isMethod) + } + + // MARK: - ImportTarget + + func testImportTargetRoundTrip() throws { + let json = """ + {"module": "os.path", "symbols": ["join", "exists"], "line": 3} + """.data(using: .utf8)! + + let imp = try decoder.decode(ImportTarget.self, from: json) + XCTAssertEqual(imp.module, "os.path") + XCTAssertEqual(imp.symbols, ["join", "exists"]) + XCTAssertEqual(imp.line, 3) + } + + // MARK: - ExtendedFileAtoms + + func testExtendedFileAtomsRoundTrip() throws { + let atoms = ExtendedFileAtoms( + filePath: "src/main.py", + language: "python", + atoms: [ + StructuralAtom(name: "main", kind: "function", startLine: 1, endLine: 10, source: "def main(): pass") + ], + definitions: [ + SymbolDefinition(name: "main", kind: "function", startLine: 1, endLine: 10, visibility: "exported") + ], + callSites: [ + CallSite(caller: "main", callee: "print", line: 5) + ], + imports: [ + ImportTarget(module: "os", line: 1) + ] + ) + + let data = try encoder.encode(atoms) + let decoded = try decoder.decode(ExtendedFileAtoms.self, from: data) + XCTAssertEqual(decoded.filePath, "src/main.py") + XCTAssertEqual(decoded.language, "python") + XCTAssertEqual(decoded.atoms.count, 1) + XCTAssertEqual(decoded.definitions.count, 1) + XCTAssertEqual(decoded.callSites.count, 1) + XCTAssertEqual(decoded.imports.count, 1) + } + + // MARK: - CrossFileRef + + func testCrossFileRefRoundTrip() throws { + let json = """ + { + "from_file": "a.py", "from_line": 10, "from_symbol": "foo", + "to_file": "b.py", "to_line": 20, "to_symbol": "bar", + "kind": "calls" + } + """.data(using: .utf8)! + + let ref = try decoder.decode(CrossFileRef.self, from: json) + XCTAssertEqual(ref.fromFile, "a.py") + XCTAssertEqual(ref.toFile, "b.py") + XCTAssertEqual(ref.kind, "calls") + } + + // MARK: - FileNode + + func testFileNodeRoundTrip() throws { + let json = """ + { + "file_path": "src/main.py", + "language": "python", + "definitions_count": 5, + "imports": ["src/utils.py"], + "imported_by": [], + "calls_into": ["src/utils.py"], + "called_from": [], + "pagerank": 0.75, + "topo_order": 2 + } + """.data(using: .utf8)! + + let node = try decoder.decode(FileNode.self, from: json) + XCTAssertEqual(node.filePath, "src/main.py") + XCTAssertEqual(node.definitionsCount, 5) + XCTAssertEqual(node.pagerank, 0.75) + XCTAssertEqual(node.topoOrder, 2) + } + + // MARK: - RepoAnalysis + + func testRepoAnalysisEdgePairs() throws { + let analysis = RepoAnalysis( + provider: "github_repo", resourceId: "test/repo", version: "v1", + files: [], crossReferences: [], readingOrder: [], + dependencyEdges: [["a.py", "b.py"], ["b.py", "c.py"]], + callGraphEdges: [["a.py", "c.py"]] + ) + XCTAssertEqual(analysis.dependencyEdgePairs.count, 2) + XCTAssertEqual(analysis.callGraphEdgePairs.count, 1) + } + + // MARK: - FileSummary, ReadingStep, FileCluster, RepoSummary + + func testRepoSummaryRoundTrip() throws { + let summary = RepoSummary( + provider: "github_repo", resourceId: "test/repo", version: "v1", + summary: "A test repo", + fileSummaries: [ + FileSummary(filePath: "main.py", oneLiner: "Entry point", role: "core", keyExports: ["main"]) + ], + readingOrder: [ + ReadingStep(filePath: "main.py", order: 1, narrative: "Start here") + ], + entryPoints: ["main.py"], + fileClusters: [ + FileCluster(name: "Core", description: "Core files", files: ["main.py"]) + ] + ) + + let data = try encoder.encode(summary) + let decoded = try decoder.decode(RepoSummary.self, from: data) + XCTAssertEqual(decoded.summary, "A test repo") + XCTAssertEqual(decoded.fileSummaries.count, 1) + XCTAssertEqual(decoded.readingOrder.count, 1) + XCTAssertEqual(decoded.entryPoints, ["main.py"]) + } + + // MARK: - ReadingGuide + + func testReadingGuideRoundTrip() throws { + let guide = ReadingGuide( + title: "Reading Guide: test/repo", + chapters: [ + Chapter( + id: "ch_01", + title: "Getting Started", + summary: "Overview", + sections: [ + Section( + title: "Entry Point", + content: "Start with main.py", + codeReferences: [ + CodeReference(startLine: 1, endLine: 10, label: "main.py - main()") + ] + ) + ], + prerequisites: [], + next: "ch_02" + ) + ] + ) + + let data = try encoder.encode(guide) + let decoded = try decoder.decode(ReadingGuide.self, from: data) + XCTAssertEqual(decoded.title, "Reading Guide: test/repo") + XCTAssertEqual(decoded.chapters.count, 1) + XCTAssertEqual(decoded.chapters[0].sections.count, 1) + XCTAssertEqual(decoded.chapters[0].sections[0].codeReferences.count, 1) + } + + // MARK: - Diff types + + func testDiffFileRoundTrip() throws { + let diff = DiffFile( + path: "src/main.py", + status: .modified, + hunks: [ + DiffHunk( + oldStart: 1, oldCount: 3, newStart: 1, newCount: 4, + lines: [ + DiffLine(content: "import os", type: .context, oldLineNumber: 1, newLineNumber: 1), + DiffLine(content: "import sys", type: .deletion, oldLineNumber: 2), + DiffLine(content: "import sys, json", type: .addition, newLineNumber: 2), + ] + ) + ] + ) + + let data = try encoder.encode(diff) + let decoded = try decoder.decode(DiffFile.self, from: data) + XCTAssertEqual(decoded.path, "src/main.py") + XCTAssertEqual(decoded.status, .modified) + XCTAssertEqual(decoded.hunks.count, 1) + XCTAssertEqual(decoded.hunks[0].lines.count, 3) + } + + // MARK: - LLMUsage + + func testLLMUsageArithmetic() { + var usage1 = LLMUsage(inputTokens: 100, outputTokens: 200, cacheCreationTokens: 10, cacheReadTokens: 5) + let usage2 = LLMUsage(inputTokens: 50, outputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 3) + + let sum = usage1 + usage2 + XCTAssertEqual(sum.inputTokens, 150) + XCTAssertEqual(sum.outputTokens, 300) + XCTAssertEqual(sum.cacheCreationTokens, 10) + XCTAssertEqual(sum.cacheReadTokens, 8) + + usage1 += usage2 + XCTAssertEqual(usage1.inputTokens, 150) + XCTAssertEqual(usage1.outputTokens, 300) + } + + func testLLMUsageTotalTokens() { + let usage = LLMUsage(inputTokens: 100, outputTokens: 200, cacheCreationTokens: 10, cacheReadTokens: 5) + XCTAssertEqual(usage.totalTokens, 315) + } + + func testLLMUsageRoundTrip() throws { + let json = """ + {"input_tokens": 100, "output_tokens": 200, "cache_creation_tokens": 10, "cache_read_tokens": 5} + """.data(using: .utf8)! + + let usage = try decoder.decode(LLMUsage.self, from: json) + XCTAssertEqual(usage.inputTokens, 100) + XCTAssertEqual(usage.outputTokens, 200) + } + + // MARK: - Stage enum + + func testStageCases() { + XCTAssertEqual(Stage.allCases.count, 5) + XCTAssertEqual(Stage.parse.rawValue, "parse") + XCTAssertEqual(Stage.annotate.rawValue, "annotate") + } + + // MARK: - AnyCodable + + func testAnyCodableString() throws { + let json = """ + {"key": "value"} + """.data(using: .utf8)! + + let dict = try decoder.decode([String: AnyCodable].self, from: json) + XCTAssertEqual(dict["key"]?.value as? String, "value") + } + + func testAnyCodableInt() throws { + let json = """ + {"key": 42} + """.data(using: .utf8)! + + let dict = try decoder.decode([String: AnyCodable].self, from: json) + XCTAssertEqual(dict["key"]?.value as? Int, 42) + } + + func testAnyCodableNested() throws { + let json = """ + {"key": {"nested": true}} + """.data(using: .utf8)! + + let dict = try decoder.decode([String: AnyCodable].self, from: json) + let nested = dict["key"]?.value as? [String: Any] + XCTAssertNotNil(nested) + XCTAssertEqual(nested?["nested"] as? Bool, true) + } + + func testAnyCodableArray() throws { + let json = """ + {"key": [1, 2, 3]} + """.data(using: .utf8)! + + let dict = try decoder.decode([String: AnyCodable].self, from: json) + let arr = dict["key"]?.value as? [Any] + XCTAssertEqual(arr?.count, 3) + } + + // MARK: - DesignTokens + + func testAnnotationMarkers() { + XCTAssertEqual(DesignTokens.annotationMarkers.count, 8) + XCTAssertEqual(DesignTokens.annotationMarkers["insight"], "\u{25CF}") + XCTAssertEqual(DesignTokens.annotationMarkers["warning"], "\u{25B2}") + } + + func testAnnotationKinds() { + XCTAssertEqual(DesignTokens.annotationKinds.count, 8) + XCTAssertTrue(DesignTokens.annotationKinds.contains("insight")) + XCTAssertTrue(DesignTokens.annotationKinds.contains("critique")) + } + + func testAnnotationColors() { + XCTAssertEqual(DesignTokens.Colors.annotation.count, 8) + XCTAssertNotNil(DesignTokens.Colors.annotation["insight"]) + } + + func testBracketColors() { + XCTAssertEqual(DesignTokens.Colors.bracket.count, 6) + } + + func testSpacingConstants() { + XCTAssertEqual(DesignTokens.Spacing.lineHeight, 22) + XCTAssertEqual(DesignTokens.Spacing.gutterWidth, 32) + XCTAssertEqual(DesignTokens.Spacing.codeMaxWidth, 900) + } + + func testTypographyConstants() { + XCTAssertEqual(DesignTokens.Typography.codeSize, 13) + XCTAssertEqual(DesignTokens.Typography.codeLineHeight, 20) + } +} diff --git a/desktop/macos-claude/Tests/LionsPipelineTests/PipelineTests.swift b/desktop/macos-claude/Tests/LionsPipelineTests/PipelineTests.swift new file mode 100644 index 0000000..607aedc --- /dev/null +++ b/desktop/macos-claude/Tests/LionsPipelineTests/PipelineTests.swift @@ -0,0 +1,247 @@ +import XCTest +@testable import LionsPipeline +@testable import LionsModels +@testable import LionsLLM + +// MARK: - Token Budget Tests + +final class TokenBudgetTests: XCTestCase { + + func testFormulaZeroLines() { + let result = TokenBudget.maxTokensForSource("") + XCTAssertEqual(result, TokenBudget.baseMaxTokens + 1 * TokenBudget.tokensPerLine) + } + + func testFormulaSmallFile() { + let source = (0..<100).map { "line \($0)" }.joined(separator: "\n") + // 100 elements joined by \n = 100 components when split + let expected = min( + TokenBudget.baseMaxTokens + 100 * TokenBudget.tokensPerLine, + TokenBudget.maxOutputTokens + ) + XCTAssertEqual(TokenBudget.maxTokensForSource(source), expected) + } + + func testFormulaCappedAtMax() { + let source = (0..<10000).map { "line \($0)" }.joined(separator: "\n") + XCTAssertEqual(TokenBudget.maxTokensForSource(source), TokenBudget.maxOutputTokens) + } + + func testConstants() { + XCTAssertEqual(TokenBudget.baseMaxTokens, 8192) + XCTAssertEqual(TokenBudget.tokensPerLine, 12) + XCTAssertEqual(TokenBudget.maxOutputTokens, 32768) + XCTAssertEqual(TokenBudget.maxRetries, 2) + } +} + +// MARK: - Prompt Templates Tests + +final class PromptTemplateTests: XCTestCase { + + func testAnnotationTemplateNonEmpty() { + XCTAssertFalse(PromptTemplates.annotation.isEmpty) + XCTAssertTrue(PromptTemplates.annotation.contains("JSON")) + } + + func testClusterTemplateNonEmpty() { + XCTAssertFalse(PromptTemplates.cluster.isEmpty) + } + + func testSynthesisTemplateNonEmpty() { + XCTAssertFalse(PromptTemplates.synthesis.isEmpty) + } + + func testGuideTemplateNonEmpty() { + XCTAssertFalse(PromptTemplates.guide.isEmpty) + } +} + +// MARK: - Annotation Builder Tests + +final class AnnotationBuilderTests: XCTestCase { + + func testBuildPromptContainsSource() { + let source = "def hello():\n print('world')" + let (prompt, declarations) = AnnotationBuilder.buildPrompt( + source: source, filePath: "hello.py", atoms: [] + ) + XCTAssertTrue(prompt.contains("hello.py")) + XCTAssertTrue(prompt.contains("print('world')")) + XCTAssertTrue(declarations.isEmpty) + } + + func testBuildPromptWithAtoms() { + let atom = StructuralAtom( + name: "MyClass", kind: "class", + startLine: 1, endLine: 10, source: "class MyClass: pass" + ) + let (prompt, declarations) = AnnotationBuilder.buildPrompt( + source: "class MyClass: pass", filePath: "my.py", atoms: [atom] + ) + XCTAssertEqual(declarations.count, 1) + XCTAssertTrue(prompt.contains("MyClass")) + } + + func testBuildPromptWithCrossFileContext() { + let (prompt, _) = AnnotationBuilder.buildPrompt( + source: "import os", filePath: "main.py", atoms: [], + crossFileContext: "Context: This file depends on utils.py" + ) + XCTAssertTrue(prompt.contains("depends on utils.py")) + } + + func testBuildPromptLineNumbers() { + let source = "line1\nline2\nline3" + let (prompt, _) = AnnotationBuilder.buildPrompt( + source: source, filePath: "test.py", atoms: [] + ) + XCTAssertTrue(prompt.contains(" 1 |")) + XCTAssertTrue(prompt.contains(" 2 |")) + XCTAssertTrue(prompt.contains(" 3 |")) + } +} + +// MARK: - Summary Builder Tests + +final class SummaryBuilderTests: XCTestCase { + + func testClusterFilesEmptyAnalysis() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [], crossReferences: [], readingOrder: [], + dependencyEdges: [], callGraphEdges: [] + ) + let clusters = SummaryBuilder.clusterFiles(analysis: analysis) + XCTAssertTrue(clusters.isEmpty) + } + + func testClusterFilesGroupsByDirectory() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [ + FileNode(filePath: "src/a.py", language: "python", definitionsCount: 3, pagerank: 0.5, topoOrder: 1), + FileNode(filePath: "src/b.py", language: "python", definitionsCount: 2, pagerank: 0.3, topoOrder: 2), + FileNode(filePath: "tests/test_a.py", language: "python", definitionsCount: 1, pagerank: 0.1, topoOrder: 3), + FileNode(filePath: "tests/test_b.py", language: "python", definitionsCount: 1, pagerank: 0.1, topoOrder: 4), + ], + crossReferences: [], readingOrder: ["src/a.py", "src/b.py"], + dependencyEdges: [], callGraphEdges: [] + ) + let clusters = SummaryBuilder.clusterFiles(analysis: analysis) + XCTAssertGreaterThanOrEqual(clusters.count, 1) + let allFiles = clusters.flatMap(\.files) + XCTAssertEqual(Set(allFiles).count, 4) + } + + func testBuildClusterInput() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [ + FileNode(filePath: "src/main.py", language: "python", definitionsCount: 5, + imports: ["src/utils.py"], pagerank: 0.8, topoOrder: 1), + ], + crossReferences: [], readingOrder: ["src/main.py"], + dependencyEdges: [], callGraphEdges: [] + ) + let cluster = FileCluster(name: "Core", files: ["src/main.py"]) + let input = SummaryBuilder.buildClusterInput(cluster: cluster, analysis: analysis) + XCTAssertTrue(input.contains("Core")) + XCTAssertTrue(input.contains("src/main.py")) + } + + func testBuildSynthesisInput() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test/repo", version: "v1", + files: [ + FileNode(filePath: "main.py", language: "python", definitionsCount: 3, pagerank: 0.9, topoOrder: 1) + ], + crossReferences: [], readingOrder: ["main.py"], + dependencyEdges: [], callGraphEdges: [] + ) + let clusters = [FileCluster(name: "Core", files: ["main.py"])] + let summaries = [FileSummary(filePath: "main.py", oneLiner: "Entry point", role: "core", keyExports: ["main"])] + + let input = SummaryBuilder.buildSynthesisInput( + clusters: clusters, fileSummaries: summaries, analysis: analysis + ) + XCTAssertTrue(input.contains("test/repo")) + XCTAssertTrue(input.contains("Entry point")) + XCTAssertTrue(input.contains("PageRank")) + } +} + +// MARK: - Guide Builder Tests + +final class GuideBuilderTests: XCTestCase { + + func testBuildGuideInput() { + let summary = RepoSummary( + provider: "local", resourceId: "test/repo", version: "v1", + summary: "A test repo", + fileSummaries: [ + FileSummary(filePath: "main.py", oneLiner: "Entry point", role: "core", keyExports: ["main"]) + ], + readingOrder: [ReadingStep(filePath: "main.py", order: 1, narrative: "Start here")], + entryPoints: ["main.py"], + fileClusters: [FileCluster(name: "Core", files: ["main.py"])] + ) + let analysis = RepoAnalysis( + provider: "local", resourceId: "test/repo", version: "v1", + files: [FileNode(filePath: "main.py", language: "python", definitionsCount: 3, pagerank: 0.9, topoOrder: 1)], + crossReferences: [], readingOrder: ["main.py"], + dependencyEdges: [], callGraphEdges: [] + ) + + let input = GuideBuilder.buildGuideInput(summary: summary, analysis: analysis) + XCTAssertTrue(input.contains("test/repo")) + XCTAssertTrue(input.contains("Entry point")) + } +} + +// MARK: - Cross File Context Tests + +final class CrossFileContextTests: XCTestCase { + + func testBuildReturnsContextForExistingFile() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [FileNode(filePath: "solo.py", language: "python", definitionsCount: 1, pagerank: 0.5, topoOrder: 1)], + crossReferences: [], readingOrder: ["solo.py"], + dependencyEdges: [], callGraphEdges: [] + ) + let ctx = CrossFileContext.build(filePath: "solo.py", analysis: analysis) + XCTAssertTrue(ctx.contains("Cross-File Context")) + } + + func testBuildReturnsEmptyForMissingFile() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [], crossReferences: [], readingOrder: [], + dependencyEdges: [], callGraphEdges: [] + ) + let ctx = CrossFileContext.build(filePath: "missing.py", analysis: analysis) + XCTAssertTrue(ctx.isEmpty) + } + + func testBuildIncludesCrossReferences() { + let analysis = RepoAnalysis( + provider: "local", resourceId: "test", version: "v1", + files: [ + FileNode(filePath: "a.py", language: "python", definitionsCount: 2, + imports: ["b.py"], pagerank: 0.8, topoOrder: 1), + FileNode(filePath: "b.py", language: "python", definitionsCount: 1, + importedBy: ["a.py"], pagerank: 0.3, topoOrder: 2), + ], + crossReferences: [ + CrossFileRef(fromFile: "b.py", fromLine: 10, fromSymbol: "caller", + toFile: "a.py", toLine: 5, toSymbol: "bar", kind: "calls") + ], + readingOrder: ["a.py", "b.py"], + dependencyEdges: [["a.py", "b.py"]], callGraphEdges: [["b.py", "a.py"]] + ) + let ctx = CrossFileContext.build(filePath: "a.py", analysis: analysis) + // a.py imports b.py, and b.py calls into a.py's "bar" symbol + XCTAssertTrue(ctx.contains("b.py")) + } +} diff --git a/desktop/macos-claude/Tests/LionsSourcesTests/SourcesTests.swift b/desktop/macos-claude/Tests/LionsSourcesTests/SourcesTests.swift new file mode 100644 index 0000000..9f9c982 --- /dev/null +++ b/desktop/macos-claude/Tests/LionsSourcesTests/SourcesTests.swift @@ -0,0 +1,166 @@ +import XCTest +@testable import LionsSources +@testable import LionsModels + +// MARK: - Language Detection Tests + +final class LanguageDetectionTests: XCTestCase { + + func testAllExtensions() { + XCTAssertEqual(LanguageDetection.detect(filePath: "main.py"), "python") + XCTAssertEqual(LanguageDetection.detect(filePath: "lib.c"), "c") + XCTAssertEqual(LanguageDetection.detect(filePath: "lib.h"), "c") + XCTAssertEqual(LanguageDetection.detect(filePath: "server.go"), "go") + XCTAssertEqual(LanguageDetection.detect(filePath: "main.rs"), "rust") + XCTAssertEqual(LanguageDetection.detect(filePath: "app.ts"), "typescript") + XCTAssertEqual(LanguageDetection.detect(filePath: "Component.tsx"), "tsx") + XCTAssertEqual(LanguageDetection.detect(filePath: "index.js"), "javascript") + XCTAssertEqual(LanguageDetection.detect(filePath: "App.jsx"), "javascript") + XCTAssertEqual(LanguageDetection.detect(filePath: "main.cpp"), "cpp") + XCTAssertEqual(LanguageDetection.detect(filePath: "main.cc"), "cpp") + XCTAssertEqual(LanguageDetection.detect(filePath: "main.cxx"), "cpp") + XCTAssertEqual(LanguageDetection.detect(filePath: "header.hpp"), "cpp") + XCTAssertEqual(LanguageDetection.detect(filePath: "header.hh"), "cpp") + XCTAssertEqual(LanguageDetection.detect(filePath: "Main.java"), "java") + } + + func testDetectWithFullPath() { + XCTAssertEqual(LanguageDetection.detect(filePath: "src/lib/utils.py"), "python") + XCTAssertEqual(LanguageDetection.detect(filePath: "a/b/c/deep.ts"), "typescript") + } + + func testIsSupported() { + XCTAssertTrue(LanguageDetection.isSupported(filePath: "main.py")) + XCTAssertTrue(LanguageDetection.isSupported(filePath: "main.rs")) + XCTAssertFalse(LanguageDetection.isSupported(filePath: "readme.md")) + XCTAssertFalse(LanguageDetection.isSupported(filePath: "config.yaml")) + } + + func testUnsupportedExtensionReturnsNil() { + XCTAssertNil(LanguageDetection.detect(filePath: "notes.txt")) + XCTAssertNil(LanguageDetection.detect(filePath: "style.css")) + XCTAssertNil(LanguageDetection.detect(filePath: "page.html")) + } + + func testSupportedExtensionsList() { + let exts = LanguageDetection.supportedExtensions + XCTAssertFalse(exts.isEmpty) + XCTAssertTrue(exts.contains(".py")) + XCTAssertTrue(exts.contains(".ts")) + } +} + +// MARK: - URL Parsing Tests + +final class URLParsingTests: XCTestCase { + + func testParseRepoURL() { + let result = parseGitHubURL("https://github.com/owner/repo") + XCTAssertEqual(result, .repo(owner: "owner", repo: "repo", ref: nil, path: nil)) + } + + func testParseRepoURLWithBlob() { + let result = parseGitHubURL("https://github.com/owner/repo/blob/main/src/file.py") + XCTAssertEqual(result, .repo(owner: "owner", repo: "repo", ref: "main", path: "src/file.py")) + } + + func testParsePRURL() { + let result = parseGitHubURL("https://github.com/owner/repo/pull/42") + XCTAssertEqual(result, .pullRequest(owner: "owner", repo: "repo", number: 42)) + } + + func testParseGistURL() { + let result = parseGitHubURL("https://gist.github.com/user/abc123def456") + XCTAssertEqual(result, .gist(id: "abc123def456", filename: nil)) + } + + func testParseInvalidURL() { + XCTAssertNil(parseGitHubURL("not-a-url")) + XCTAssertNil(parseGitHubURL("https://example.com/something")) + } +} + +// MARK: - Unified Diff Parser Tests + +final class UnifiedDiffParserTests: XCTestCase { + + func testParseSimpleDiff() { + let diff = """ + diff --git a/file.py b/file.py + --- a/file.py + +++ b/file.py + @@ -1,3 +1,4 @@ + line1 + +added line + line2 + line3 + """ + + let files = GitDiffProvider.parseUnifiedDiff(diff) + XCTAssertEqual(files.count, 1) + XCTAssertEqual(files[0].path, "file.py") + XCTAssertEqual(files[0].status, .modified) + XCTAssertEqual(files[0].hunks.count, 1) + + let additions = files[0].hunks[0].lines.filter { $0.type == .addition } + XCTAssertEqual(additions.count, 1) + XCTAssertEqual(additions[0].content, "added line") + } + + func testParseAddedFile() { + let diff = """ + diff --git a/new_file.py b/new_file.py + --- /dev/null + +++ b/new_file.py + @@ -0,0 +1,2 @@ + +line1 + +line2 + """ + + let files = GitDiffProvider.parseUnifiedDiff(diff) + XCTAssertEqual(files.count, 1) + XCTAssertEqual(files[0].status, .added) + } + + func testParseDeletedFile() { + let diff = """ + diff --git a/old_file.py b/old_file.py + --- a/old_file.py + +++ /dev/null + @@ -1,2 +0,0 @@ + -line1 + -line2 + """ + + let files = GitDiffProvider.parseUnifiedDiff(diff) + XCTAssertEqual(files.count, 1) + XCTAssertEqual(files[0].status, .deleted) + } + + func testParseMultipleFiles() { + let diff = """ + diff --git a/a.py b/a.py + --- a/a.py + +++ b/a.py + @@ -1,2 +1,3 @@ + original + +new in a + end + diff --git a/b.py b/b.py + --- /dev/null + +++ b/b.py + @@ -0,0 +1,1 @@ + +brand new + """ + + let files = GitDiffProvider.parseUnifiedDiff(diff) + XCTAssertEqual(files.count, 2) + XCTAssertEqual(files[0].path, "a.py") + XCTAssertEqual(files[1].path, "b.py") + } + + func testParseEmptyDiff() { + let files = GitDiffProvider.parseUnifiedDiff("") + XCTAssertTrue(files.isEmpty) + } +} diff --git a/desktop/macos-claude/Tests/LionsStorageTests/GRDBStorageTests.swift b/desktop/macos-claude/Tests/LionsStorageTests/GRDBStorageTests.swift new file mode 100644 index 0000000..ccf558d --- /dev/null +++ b/desktop/macos-claude/Tests/LionsStorageTests/GRDBStorageTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import LionsStorage +@testable import LionsModels + +final class GRDBStorageTests: XCTestCase { + private var storage: GRDBStorage! + + override func setUp() async throws { + storage = try GRDBStorage(inMemory: true) + } + + // MARK: - Annotations + + func testStoreAndGetAnnotation() async throws { + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "main.py", contentHash: "abc123", + annotationJSON: #"{"groups":[]}"#, sourceText: "print('hello')" + ) + + let result = try await storage.getAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", path: "main.py" + ) + XCTAssertNotNil(result) + XCTAssertEqual(result?.contentHash, "abc123") + XCTAssertEqual(result?.annotationJSON, #"{"groups":[]}"#) + XCTAssertEqual(result?.sourceText, "print('hello')") + } + + func testGetAnnotationNotFound() async throws { + let result = try await storage.getAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", path: "missing.py" + ) + XCTAssertNil(result) + } + + func testStoreAnnotationUpserts() async throws { + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "main.py", contentHash: "hash1", annotationJSON: "old", sourceText: nil + ) + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "main.py", contentHash: "hash2", annotationJSON: "new", sourceText: nil + ) + + let result = try await storage.getAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", path: "main.py" + ) + XCTAssertEqual(result?.contentHash, "hash2") + XCTAssertEqual(result?.annotationJSON, "new") + } + + func testListAnnotations() async throws { + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "a.py", contentHash: "h1", annotationJSON: "{}", sourceText: nil + ) + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "b.py", contentHash: "h2", annotationJSON: "{}", sourceText: nil + ) + + let entries = try await storage.listAnnotations() + XCTAssertEqual(entries.count, 2) + let paths = Set(entries.map(\.path)) + XCTAssertTrue(paths.contains("a.py")) + XCTAssertTrue(paths.contains("b.py")) + } + + // MARK: - Repo Analysis + + func testStoreAndGetRepoAnalysis() async throws { + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: #"{"files":[]}"#, displayName: "Test Repo" + ) + + let result = try await storage.getRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1" + ) + XCTAssertNotNil(result) + XCTAssertEqual(result?.analysisJSON, #"{"files":[]}"#) + XCTAssertEqual(result?.displayName, "Test Repo") + } + + func testGetRepoAnalysisLatest() async throws { + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: #"{"version":"v1"}"#, displayName: nil + ) + + let result = try await storage.getRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "latest" + ) + XCTAssertNotNil(result) + } + + func testStoreAndGetRepoSummary() async throws { + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: "{}", displayName: nil + ) + try await storage.storeRepoSummary( + provider: "github", resourceId: "owner/repo", version: "v1", + summaryJSON: #"{"summary":"A repo"}"# + ) + + let result = try await storage.getRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1" + ) + XCTAssertEqual(result?.summaryJSON, #"{"summary":"A repo"}"#) + } + + func testStoreAndGetReadingGuide() async throws { + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: "{}", displayName: nil + ) + try await storage.storeReadingGuide( + provider: "github", resourceId: "owner/repo", version: "v1", + guideJSON: #"{"chapters":[]}"# + ) + + let guide = try await storage.getReadingGuide( + provider: "github", resourceId: "owner/repo", version: "v1" + ) + XCTAssertEqual(guide, #"{"chapters":[]}"#) + } + + func testUpdateDisplayName() async throws { + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: "{}", displayName: nil + ) + try await storage.updateDisplayName( + provider: "github", resourceId: "owner/repo", version: "v1", + displayName: "New Name" + ) + + let result = try await storage.getRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1" + ) + XCTAssertEqual(result?.displayName, "New Name") + } + + // MARK: - Pipeline Costs + + func testRecordAndGetCosts() async throws { + let record = PipelineCostRecord( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "file.py", stage: "annotate", model: "claude-3", + inputTokens: 1000, outputTokens: 500 + ) + try await storage.storePipelineCost(record) + + let costs = try await storage.getPipelineCosts(provider: "github", resourceId: "owner/repo") + XCTAssertEqual(costs.count, 1) + XCTAssertEqual(costs[0].stage, "annotate") + XCTAssertEqual(costs[0].inputTokens, 1000) + } + + func testGetCostSummary() async throws { + try await storage.storePipelineCost(PipelineCostRecord( + provider: "github", resourceId: "owner/repo", version: "v1", + path: nil, stage: "annotate", model: "claude-3", + inputTokens: 1000, outputTokens: 500 + )) + try await storage.storePipelineCost(PipelineCostRecord( + provider: "github", resourceId: "owner/repo", version: "v1", + path: nil, stage: "summarize", model: "claude-3", + inputTokens: 2000, outputTokens: 800 + )) + + let summary = try await storage.getPipelineCostSummary( + provider: "github", resourceId: "owner/repo" + ) + XCTAssertEqual(summary.totalCalls, 2) + XCTAssertEqual(summary.totalInputTokens, 3000) + XCTAssertEqual(summary.totalOutputTokens, 1300) + XCTAssertEqual(summary.byStage.count, 2) + } + + func testEmptyCostSummary() async throws { + let summary = try await storage.getPipelineCostSummary(provider: nil, resourceId: nil) + XCTAssertEqual(summary.totalCalls, 0) + XCTAssertEqual(summary.totalInputTokens, 0) + } + + // MARK: - List Repos + + func testListRepos() async throws { + try await storage.storeAnnotation( + provider: "github", resourceId: "owner/repo", version: "v1", + path: "a.py", contentHash: "h1", annotationJSON: "{}", sourceText: nil + ) + try await storage.storeRepoAnalysis( + provider: "github", resourceId: "owner/repo", version: "v1", + analysisJSON: "{}", displayName: "Test" + ) + try await storage.storeRepoSummary( + provider: "github", resourceId: "owner/repo", version: "v1", + summaryJSON: #"{"summary":"A repo"}"# + ) + + let repos = try await storage.listRepos() + XCTAssertEqual(repos.count, 1) + XCTAssertEqual(repos[0].displayName, "Test") + } +} diff --git a/desktop/macos-claude/Tests/UITests/SheetTextFieldTest.swift b/desktop/macos-claude/Tests/UITests/SheetTextFieldTest.swift new file mode 100644 index 0000000..78ea1e3 --- /dev/null +++ b/desktop/macos-claude/Tests/UITests/SheetTextFieldTest.swift @@ -0,0 +1,212 @@ +/// Minimal test: can a SwiftUI TextField accept keyboard input in different window types? +/// +/// swiftc -framework AppKit -framework SwiftUI Tests/UITests/SheetTextFieldTest.swift -o /tmp/sheet_test && /tmp/sheet_test + +import AppKit +import SwiftUI + +// MARK: - Helpers + +func findTextField(in view: NSView?) -> NSTextField? { + guard let view = view else { return nil } + if let tf = view as? NSTextField, tf.isEditable { return tf } + for sub in view.subviews { + if let found = findTextField(in: sub) { return found } + } + return nil +} + +func dumpViews(_ view: NSView?, indent: String = "") { + guard let view = view else { return } + var s = "\(indent)\(type(of: view))" + if let tf = view as? NSTextField { s += " editable=\(tf.isEditable)" } + print(s) + for sub in view.subviews { dumpViews(sub, indent: indent + " ") } +} + +func testTextField(in window: NSWindow, name: String) -> Bool { + print("[\(name)]") + print(" window.isKey=\(window.isKeyWindow) canBecomeKey=\(window.canBecomeKey)") + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + print(" After makeKey: isKey=\(window.isKeyWindow)") + + guard let tf = findTextField(in: window.contentView) else { + print(" FAIL: No editable TextField found") + dumpViews(window.contentView, indent: " ") + return false + } + + // Click the text field + let pt = tf.convert(NSPoint(x: tf.bounds.midX, y: tf.bounds.midY), to: nil) + let ts = ProcessInfo.processInfo.systemUptime + for (evType, pressure) in [(NSEvent.EventType.leftMouseDown, Float(1.0)), + (NSEvent.EventType.leftMouseUp, Float(0.0))] { + guard let ev = NSEvent.mouseEvent(with: evType, location: pt, modifierFlags: [], + timestamp: ts, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: pressure) else { continue } + NSApp.sendEvent(ev) + } + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + + let fr = window.firstResponder + print(" firstResponder=\(type(of: fr))") + + // Type "x" via NSApp + let t2 = ProcessInfo.processInfo.systemUptime + for evType in [NSEvent.EventType.keyDown, NSEvent.EventType.keyUp] { + guard let ev = NSEvent.keyEvent(with: evType, location: .zero, modifierFlags: [], + timestamp: t2, windowNumber: window.windowNumber, context: nil, + characters: "x", charactersIgnoringModifiers: "x", + isARepeat: false, keyCode: 7) else { continue } + NSApp.sendEvent(ev) + } + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + + let editorText = (window.firstResponder as? NSTextView)?.string ?? "" + let fieldText = tf.stringValue + let ok = editorText.contains("x") || fieldText.contains("x") + print(" editor=\"\(editorText)\" field=\"\(fieldText)\" -> \(ok ? "PASS" : "FAIL")") + return ok +} + +// MARK: - Views + +class Holder: ObservableObject { + @Published var text = "" +} + +struct SimpleForm: View { + @ObservedObject var holder: Holder + var body: some View { + VStack(spacing: 16) { + Text("New Project").font(.title2) + TextField("Local path or GitHub URL", text: $holder.text) + .textFieldStyle(.roundedBorder) + HStack { + Button("Cancel") {}.keyboardShortcut(.cancelAction) + Spacer() + Button("Add") {}.keyboardShortcut(.defaultAction).disabled(holder.text.isEmpty) + } + }.padding(24).frame(width: 420) + } +} + +// MARK: - Main + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.activate(ignoringOtherApps: true) +RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + +print("=== SwiftUI TextField Keyboard Test ===") +print("macOS \(ProcessInfo.processInfo.operatingSystemVersionString)") +print("All events routed via NSApp.sendEvent()") +print("") + +var results: [(String, Bool)] = [] + +// TEST 1: Regular NSWindow +do { + let h = Holder() + let w = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 450, height: 200), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + w.title = "Test 1" + w.contentView = NSHostingView(rootView: SimpleForm(holder: h)) + w.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(1.0)) + let ok = testTextField(in: w, name: "NSWindow") + results.append(("NSWindow", ok)) + w.close() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +} + +print("") + +// TEST 2: Regular NSWindow with another window behind +do { + let h = Holder() + let bg = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + bg.title = "Background (main app)" + bg.contentView = NSHostingView(rootView: Text("Main").frame(width: 400, height: 300)) + bg.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + + let fg = NSWindow(contentRect: NSRect(x: 300, y: 300, width: 450, height: 200), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + fg.title = "New Project" + fg.contentView = NSHostingView(rootView: SimpleForm(holder: h)) + fg.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(1.0)) + + print(" bg.isKey=\(bg.isKeyWindow) fg.isKey=\(fg.isKeyWindow)") + let ok = testTextField(in: fg, name: "NSWindow (with bg window)") + results.append(("NSWindow + bg window", ok)) + fg.close() + bg.close() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +} + +print("") + +// TEST 3: NSPanel (non-activating, like sheets use) +do { + let h = Holder() + let bg = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + bg.title = "Background" + bg.contentView = NSHostingView(rootView: Text("Main").frame(width: 400, height: 300)) + bg.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + + let panel = NSPanel(contentRect: NSRect(x: 300, y: 300, width: 450, height: 200), + styleMask: [.titled, .closable, .nonactivatingPanel], backing: .buffered, defer: false) + panel.title = "Panel Test" + panel.contentView = NSHostingView(rootView: SimpleForm(holder: h)) + panel.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(1.0)) + + print(" bg.isKey=\(bg.isKeyWindow) panel.isKey=\(panel.isKeyWindow) canBecomeKey=\(panel.canBecomeKey)") + let ok = testTextField(in: panel, name: "NSPanel (nonactivating)") + results.append(("NSPanel (nonactivating)", ok)) + panel.close() + bg.close() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +} + +print("") + +// TEST 4: NSPanel (activating) +do { + let h = Holder() + let bg = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + bg.contentView = NSHostingView(rootView: Text("Main").frame(width: 400, height: 300)) + bg.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + + let panel = NSPanel(contentRect: NSRect(x: 300, y: 300, width: 450, height: 200), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + panel.title = "Panel Activating" + panel.contentView = NSHostingView(rootView: SimpleForm(holder: h)) + panel.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date().addingTimeInterval(1.0)) + + print(" bg.isKey=\(bg.isKeyWindow) panel.isKey=\(panel.isKeyWindow) canBecomeKey=\(panel.canBecomeKey)") + let ok = testTextField(in: panel, name: "NSPanel (activating)") + results.append(("NSPanel (activating)", ok)) + panel.close() + bg.close() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +} + +// Summary +print("") +print("=== RESULTS ===") +for (name, ok) in results { + print(" [\(ok ? "PASS" : "FAIL")] \(name)") +} + +exit(0) diff --git a/desktop/macos-claude/Tests/UITests/VerifyTextField.swift b/desktop/macos-claude/Tests/UITests/VerifyTextField.swift new file mode 100644 index 0000000..14da3b6 --- /dev/null +++ b/desktop/macos-claude/Tests/UITests/VerifyTextField.swift @@ -0,0 +1,181 @@ +/// Verify TextField keyboard input using the DialogPresenter pattern. +/// Uses KeyableWindow + NSApp.activate + CGEvent to simulate real user input. +/// +/// swiftc -framework AppKit -framework SwiftUI -framework Carbon Tests/UITests/VerifyTextField.swift -o /tmp/verify_tf && /tmp/verify_tf + +import AppKit +import SwiftUI +import Carbon + +// MARK: - KeyableWindow (same as DialogPresenter) + +class KeyableWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +// MARK: - Views + +class Holder: ObservableObject { + @Published var text = "" +} + +struct TestForm: View { + @ObservedObject var holder: Holder + var body: some View { + VStack(spacing: 16) { + Text("New Project").font(.title2) + TextField("Local path or GitHub URL", text: $holder.text) + .textFieldStyle(.roundedBorder) + HStack { + Button("Cancel") {}.keyboardShortcut(.cancelAction) + Spacer() + Button("Add") {}.keyboardShortcut(.defaultAction).disabled(holder.text.isEmpty) + } + } + .padding(24) + .frame(width: 420) + } +} + +// MARK: - Helpers + +func findTextField(in view: NSView?) -> NSTextField? { + guard let view = view else { return nil } + if let tf = view as? NSTextField, tf.isEditable { return tf } + for sub in view.subviews { if let f = findTextField(in: sub) { return f } } + return nil +} + +// MARK: - Main + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.activate(ignoringOtherApps: true) + +let holder = Holder() + +let window = KeyableWindow( + contentRect: NSRect(origin: .zero, size: NSSize(width: 420, height: 180)), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false +) +window.title = "New Project" +window.center() +window.isReleasedWhenClosed = false +window.contentView = NSHostingView(rootView: TestForm(holder: holder)) + +let controller = NSWindowController(window: window) +controller.showWindow(nil) +app.activate(ignoringOtherApps: true) +window.makeKeyAndOrderFront(nil) + +RunLoop.main.run(until: Date().addingTimeInterval(2.0)) + +print("=== DialogPresenter Verification (KeyableWindow) ===") +print("macOS \(ProcessInfo.processInfo.operatingSystemVersionString)") +print("") +print("window.isKey = \(window.isKeyWindow)") +print("window.isMain = \(window.isMainWindow)") +print("window.canBecomeKey = \(window.canBecomeKey)") +print("") + +guard let tf = findTextField(in: window.contentView) else { + print("FAIL: No editable TextField") + exit(1) +} + +window.makeFirstResponder(tf) +RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +print("firstResponder = \(type(of: window.firstResponder))") + +// Test A: NSApp.sendEvent (works in our environment) +print("") +print("[Test A] NSApp.sendEvent keyboard") +holder.text = "" +tf.stringValue = "" +window.makeFirstResponder(tf) +RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + +for (ch, code) in [("a", UInt16(0)), ("b", UInt16(11)), ("c", UInt16(8))] { + let ts = ProcessInfo.processInfo.systemUptime + for evType in [NSEvent.EventType.keyDown, .keyUp] { + if let ev = NSEvent.keyEvent(with: evType, location: .zero, modifierFlags: [], + timestamp: ts, windowNumber: window.windowNumber, context: nil, + characters: ch, charactersIgnoringModifiers: ch, + isARepeat: false, keyCode: code) { + NSApp.sendEvent(ev) + } + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) +} +RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +let aOk = holder.text.contains("abc") +print(" holder.text = \"\(holder.text)\" -> \(aOk ? "PASS" : "FAIL")") + +// Test B: CGEvent (real user input simulation) +print("") +print("[Test B] CGEvent (system-level)") +holder.text = "" +tf.stringValue = "" +window.makeFirstResponder(tf) +RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +print(" Before: isKey=\(window.isKeyWindow) isMain=\(window.isMainWindow)") + +for code in [UInt16(4), UInt16(34)] { // h, i + if let down = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) { + down.flags = [] + if let e = NSEvent(cgEvent: down) { NSApp.sendEvent(e) } + } + if let up = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: false) { + up.flags = [] + if let e = NSEvent(cgEvent: up) { NSApp.sendEvent(e) } + } + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) +} +RunLoop.main.run(until: Date().addingTimeInterval(0.3)) +let bOk = holder.text.contains("hi") +print(" holder.text = \"\(holder.text)\" -> \(bOk ? "PASS" : "FAIL")") + +// Test C: Post CGEvent to the system event stream +print("") +print("[Test C] CGEvent posted to system") +holder.text = "" +tf.stringValue = "" +window.makeFirstResponder(tf) +app.activate(ignoringOtherApps: true) +window.makeKeyAndOrderFront(nil) +RunLoop.main.run(until: Date().addingTimeInterval(0.5)) +print(" Before: isKey=\(window.isKeyWindow) isMain=\(window.isMainWindow) isActive=\(NSApp.isActive)") + +for code in [UInt16(7), UInt16(16)] { // x, y + if let down = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) { + down.post(tap: .cghidEventTap) + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + if let up = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: false) { + up.post(tap: .cghidEventTap) + } + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) +} +RunLoop.main.run(until: Date().addingTimeInterval(0.5)) +let cOk = holder.text.contains("xy") +print(" holder.text = \"\(holder.text)\" -> \(cOk ? "PASS" : "FAIL")") + +print("") +print("=== SUMMARY ===") +print(" NSApp.sendEvent: \(aOk ? "PASS" : "FAIL")") +print(" CGEvent->NSEvent->sendEvent: \(bOk ? "PASS" : "FAIL")") +print(" CGEvent.post (real HID): \(cOk ? "PASS" : "FAIL")") + +if aOk { + print("") + print("TextField accepts keyboard input when events reach it.") + if !cOk { + print("CGEvent.post failed because this process is not the foreground app.") + print("In the real Lions app (which IS foreground), keyboard input should work.") + } +} + +exit(aOk ? 0 : 1) diff --git a/desktop/macos-claude/Tests/UITests/test_keyboard.sh b/desktop/macos-claude/Tests/UITests/test_keyboard.sh new file mode 100644 index 0000000..be0193c --- /dev/null +++ b/desktop/macos-claude/Tests/UITests/test_keyboard.sh @@ -0,0 +1,219 @@ +#!/bin/bash +# Tests keyboard input with the EXACT architecture used in the real app: +# KeyableWindow + NSHostingView + NSViewRepresentable(NSTextField) +# +# Usage: bash Tests/UITests/test_keyboard.sh + +set -e +cd "$(dirname "$0")/../.." + +echo "=== Keyboard Input Test (SwiftUI + NSViewRepresentable) ===" +echo "" + +cat > /tmp/test_swiftui_tf.swift << 'SWIFT' +import AppKit +import SwiftUI + +// ---------- KeyableWindow (same as DialogPresenter) ---------- + +class KeyableWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +// ---------- AppKitTextField (same as the real app) ---------- + +struct AppKitTextField: NSViewRepresentable { + let placeholder: String + @Binding var text: String + var autoFocus: Bool = false + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.placeholderString = placeholder + field.delegate = context.coordinator + field.bezelStyle = .roundedBezel + field.stringValue = text + field.isBordered = true + field.focusRingType = .exterior + return field + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + context.coordinator.parent = self + if nsView.stringValue != text { + nsView.stringValue = text + } + if autoFocus && !context.coordinator.didFocus { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let window = nsView.window else { return } + context.coordinator.didFocus = true + window.makeFirstResponder(nsView) + } + } + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: AppKitTextField + var didFocus = false + init(_ parent: AppKitTextField) { self.parent = parent } + func controlTextDidChange(_ obj: Notification) { + guard let f = obj.object as? NSTextField else { return } + parent.text = f.stringValue + } + } +} + +// ---------- The form (same layout as NewProjectSheet) ---------- + +struct TestForm: View { + @Binding var text: String + var body: some View { + VStack(spacing: 16) { + Text("New Project").font(.title2) + AppKitTextField(placeholder: "Local path or GitHub URL", text: $text, autoFocus: true) + .frame(height: 24) + HStack { + Button("Cancel") {}.keyboardShortcut(.cancelAction) + Spacer() + Button("Add") {}.keyboardShortcut(.defaultAction).disabled(text.isEmpty) + } + } + .padding(24) + .frame(width: 420) + } +} + +// ---------- Test runner ---------- + +class AppDelegate: NSObject, NSApplicationDelegate { + var window: KeyableWindow! + var typedText = "" + + func applicationDidFinishLaunching(_ notification: Notification) { + let binding = Binding( + get: { self.typedText }, + set: { self.typedText = $0 } + ) + + window = KeyableWindow( + contentRect: NSRect(origin: .zero, size: NSSize(width: 420, height: 180)), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "New Project" + window.center() + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: TestForm(text: binding)) + + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + + // Run tests after window is fully set up + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.runTests() + } + } + + func findTextField(in view: NSView?) -> NSTextField? { + guard let view = view else { return nil } + if let tf = view as? NSTextField, tf.isEditable { return tf } + for sub in view.subviews { + if let f = findTextField(in: sub) { return f } + } + return nil + } + + func runTests() { + print("window.isKey = \(window.isKeyWindow)") + print("window.isMain = \(window.isMainWindow)") + print("isActive = \(NSApp.isActive)") + print("") + + // Find the NSTextField created by AppKitTextField + guard let tf = findTextField(in: window.contentView) else { + print("FAIL: No editable NSTextField found in NSHostingView!") + print("View hierarchy:") + dumpViews(window.contentView) + exit(1) + return + } + print("Found NSTextField: editable=\(tf.isEditable) enabled=\(tf.isEnabled)") + + // Check first responder + let fr = window.firstResponder + print("firstResponder = \(type(of: fr))") + let isEditor = fr is NSTextView + print("Is field editor: \(isEditor)") + + if !isEditor { + print("") + print("First responder is NOT field editor. Trying makeFirstResponder...") + window.makeFirstResponder(tf) + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + let fr2 = window.firstResponder + print("After makeFirstResponder: \(type(of: fr2)) isEditor=\(fr2 is NSTextView)") + } + + // Test: insertText on field editor + print("") + print("[Test] insertText via field editor") + if let editor = window.firstResponder as? NSTextView { + editor.selectAll(nil) + editor.delete(nil) + editor.insertText("test123", replacementRange: NSRange(location: NSNotFound, length: 0)) + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + print(" tf.stringValue = \"\(tf.stringValue)\"") + print(" binding text = \"\(typedText)\"") + let ok = tf.stringValue == "test123" && typedText == "test123" + print(" -> \(ok ? "PASS" : "FAIL")") + + if ok { + print("") + print("=== PASS ===") + print("NSViewRepresentable(NSTextField) in NSHostingView in KeyableWindow") + print("correctly receives keyboard input and updates SwiftUI binding.") + exit(0) + } else { + print("") + print("=== FAIL: binding not updated ===") + exit(1) + } + } else { + print(" FAIL: No field editor available") + print(" firstResponder = \(type(of: window.firstResponder))") + exit(1) + } + } + + func dumpViews(_ view: NSView?, indent: String = "") { + guard let view = view else { return } + var s = "\(indent)\(type(of: view))" + if let tf = view as? NSTextField { s += " editable=\(tf.isEditable) val=\"\(tf.stringValue)\"" } + print(s) + for sub in view.subviews { dumpViews(sub, indent: indent + " ") } + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +let d = AppDelegate() +app.delegate = d +app.run() +SWIFT + +echo "Compiling..." +swiftc -framework AppKit -framework SwiftUI /tmp/test_swiftui_tf.swift -o /tmp/test_swiftui_tf 2>&1 +echo "Running..." +echo "" + +/tmp/test_swiftui_tf & +PID=$! +sleep 8 +kill $PID 2>/dev/null +wait $PID 2>/dev/null +echo "" +echo "Done."