From 14554deba394b61ac4441a38e1a7cafb389a3cf3 Mon Sep 17 00:00:00 2001 From: Ivan Egorov Date: Sun, 5 Jul 2026 10:21:42 +0300 Subject: [PATCH] Code Intelligence backend: cycles, dead code, duplication, search (v0.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-step 9a — backend only, no UI, no AI. Deterministic detectors in devpilot-core::intel: cyclic dependencies (Tarjan SCC over the dependency graph), dead code (functions with no detected callers that are not exported or main — heuristic), duplication (identical normalized line blocks), and code search (rank symbols/paths by query overlap; answers "where is X"). AnalyzeCodeIntelligence and SearchCode use cases parse the repo and build the architecture graphs. Tauri commands via DI: analyze_code_intelligence, search_code; typed lib/ipc/intel. "Explain this class" is served by AI Chat. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 + Cargo.lock | 16 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/src/commands.rs | 36 +- apps/desktop/src-tauri/src/main.rs | 2 + apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/lib/ipc/intel.ts | 56 +++ crates/devpilot-core/src/entities/intel.rs | 66 +++ crates/devpilot-core/src/entities/mod.rs | 4 + crates/devpilot-core/src/intel.rs | 473 ++++++++++++++++++ crates/devpilot-core/src/lib.rs | 1 + .../src/usecases/code_intelligence.rs | 117 +++++ crates/devpilot-core/src/usecases/mod.rs | 2 + crates/devpilot-testing/tests/usecases.rs | 61 ++- 15 files changed, 838 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/lib/ipc/intel.ts create mode 100644 crates/devpilot-core/src/entities/intel.rs create mode 100644 crates/devpilot-core/src/intel.rs create mode 100644 crates/devpilot-core/src/usecases/code_intelligence.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d540e..56bcc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ Versioning: `0.0.x` for fixes and small steps, `0.x` for milestone releases - New `RepositoryView` feature slice with its own Zustand store; all backend access flows through the typed `lib/ipc` layer. +## [0.2.1] - 2026-07-05 + +### Added + +- Code Intelligence backend (sub-step; no UI yet), no AI: + - Deterministic detectors in `devpilot-core::intel`: cyclic dependencies + (Tarjan SCC over the dependency graph), dead code (functions with no + detected callers, not exported or `main` — heuristic), duplication + (identical normalized line blocks), and code search (rank symbols and + paths by query overlap). + - `AnalyzeCodeIntelligence` and `SearchCode` use cases (parse the repo, + build the architecture graphs, run the detectors). + - Tauri commands via DI: `analyze_code_intelligence`, `search_code`; typed + `lib/ipc/intel.ts`. + ## [0.2.0] - 2026-07-04 Milestone: you can now chat with your repository. diff --git a/Cargo.lock b/Cargo.lock index a9cc8be..e2a51e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -629,7 +629,7 @@ dependencies = [ [[package]] name = "devpilot-ai" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-stream", "async-trait", @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "devpilot-analysis" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "devpilot-core", @@ -659,7 +659,7 @@ dependencies = [ [[package]] name = "devpilot-core" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "futures-core", @@ -669,7 +669,7 @@ dependencies = [ [[package]] name = "devpilot-desktop" -version = "0.1.3" +version = "0.2.0" dependencies = [ "devpilot-ai", "devpilot-analysis", @@ -688,7 +688,7 @@ dependencies = [ [[package]] name = "devpilot-git" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "devpilot-core", @@ -699,7 +699,7 @@ dependencies = [ [[package]] name = "devpilot-scan" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "devpilot-core", @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "devpilot-storage" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "devpilot-core", @@ -723,7 +723,7 @@ dependencies = [ [[package]] name = "devpilot-testing" -version = "0.1.3" +version = "0.2.0" dependencies = [ "async-trait", "devpilot-core", diff --git a/Cargo.toml b/Cargo.toml index 7e62e00..8bc771d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["apps/desktop/src-tauri", "crates/*"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" repository = "https://github.com/s1rry/devpilot" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 911ea10..781691c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "devpilot-desktop", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index e0554fc..4a1d3be 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -7,12 +7,12 @@ use std::path::PathBuf; use devpilot_core::entities::{ - AiSettings, ArchitectureModel, ChatMessage, FileAst, ProjectMetadata, RecentProject, - RepositoryId, RepositorySource, ScanReport, SourceFile, + AiSettings, ArchitectureModel, ChatMessage, CodeIntelligenceReport, FileAst, ProjectMetadata, + RecentProject, RepositoryId, RepositorySource, ScanReport, SearchHit, SourceFile, }; use devpilot_core::usecases::{ - AnalyzeArchitecture, ChatWithRepository, ListRecentProjects, OpenProject, RemoveRecentProject, - ScanRepository, + AnalyzeArchitecture, AnalyzeCodeIntelligence, ChatWithRepository, ListRecentProjects, + OpenProject, RemoveRecentProject, ScanRepository, SearchCode, }; use futures_util::StreamExt; use tauri::ipc::Channel; @@ -127,6 +127,34 @@ pub async fn export_architecture( Ok(out_path) } +/// Runs the deterministic code-intelligence detectors (cyclic dependencies, +/// dead code, duplication) over a project. +#[tauri::command] +pub async fn analyze_code_intelligence( + path: String, + state: State<'_, AppState>, +) -> Result { + let use_case = AnalyzeCodeIntelligence::new(state.git.clone(), state.analyzer.clone()); + use_case + .execute(RepositorySource::LocalPath(PathBuf::from(path))) + .await + .map_err(|error| error.to_string()) +} + +/// Searches a project's symbols and paths for a query. +#[tauri::command] +pub async fn search_code( + path: String, + query: String, + state: State<'_, AppState>, +) -> Result, String> { + let use_case = SearchCode::new(state.git.clone(), state.analyzer.clone()); + use_case + .execute(RepositorySource::LocalPath(PathBuf::from(path)), query) + .await + .map_err(|error| error.to_string()) +} + /// Returns the current AI settings. #[tauri::command] pub async fn get_ai_settings(state: State<'_, AppState>) -> Result { diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index f47beb3..e42bcc1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -33,6 +33,8 @@ fn main() { commands::parse_file, commands::analyze_architecture, commands::export_architecture, + commands::analyze_code_intelligence, + commands::search_code, commands::get_ai_settings, commands::set_ai_settings, commands::chat, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 860038c..4fd0ead 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DevPilot", - "version": "0.2.0", + "version": "0.2.1", "identifier": "dev.devpilot.app", "build": { "beforeDevCommand": "pnpm dev", diff --git a/apps/desktop/src/lib/ipc/intel.ts b/apps/desktop/src/lib/ipc/intel.ts new file mode 100644 index 0000000..bb78830 --- /dev/null +++ b/apps/desktop/src/lib/ipc/intel.ts @@ -0,0 +1,56 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Typed wrappers and types for Code Intelligence, mirroring the core + * `CodeIntelligenceReport` and `SearchHit`. + */ + +/** A dependency cycle. */ +export interface Cycle { + nodes: string[]; +} + +/** A function with no detected callers (heuristic). */ +export interface DeadSymbol { + name: string; + file: string; + line: number; +} + +/** One location of a duplicated block. */ +export interface DuplicationLocation { + file: string; + start_line: number; + end_line: number; +} + +/** A group of duplicated code blocks. */ +export interface DuplicationGroup { + line_count: number; + occurrences: DuplicationLocation[]; +} + +/** The deterministic code-intelligence report. */ +export interface CodeIntelligenceReport { + cyclic_dependencies: Cycle[]; + dead_code: DeadSymbol[]; + duplication: DuplicationGroup[]; +} + +/** A code-search result. */ +export interface SearchHit { + path: string; + symbol: string | null; + line: number; + score: number; +} + +/** Runs the cyclic-dependency, dead-code and duplication detectors. */ +export function analyzeCodeIntelligence(path: string): Promise { + return invoke("analyze_code_intelligence", { path }); +} + +/** Searches the project's symbols and paths for a query. */ +export function searchCode(path: string, query: string): Promise { + return invoke("search_code", { path, query }); +} diff --git a/crates/devpilot-core/src/entities/intel.rs b/crates/devpilot-core/src/entities/intel.rs new file mode 100644 index 0000000..79308a3 --- /dev/null +++ b/crates/devpilot-core/src/entities/intel.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +/// A dependency cycle: a set of nodes that mutually depend on each other. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cycle { + /// Node ids forming the cycle, in a deterministic order. + pub nodes: Vec, +} + +/// A function that appears to be unused (best-effort, heuristic). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeadSymbol { + /// Function name. + pub name: String, + /// File the function is defined in. + pub file: String, + /// 1-based line the function starts on. + pub line: usize, +} + +/// One location of a duplicated block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DuplicationLocation { + /// File containing the block. + pub file: String, + /// 1-based first line of the block. + pub start_line: usize, + /// 1-based last line of the block. + pub end_line: usize, +} + +/// A group of identical code blocks found in two or more places. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DuplicationGroup { + /// Number of (normalized) lines in the block. + pub line_count: usize, + /// Where the block occurs. + pub occurrences: Vec, +} + +/// A code-search result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SearchHit { + /// File the hit is in. + pub path: String, + /// Matching symbol name, when the hit is a symbol rather than a path. + pub symbol: Option, + /// 1-based line of the hit (the symbol's line, or 1 for a path hit). + pub line: usize, + /// Relevance score; higher is better. + pub score: usize, +} + +/// The deterministic code-intelligence report for a repository. +/// +/// All findings are computed from the AST and architecture graphs; the dead +/// code and duplication detectors are heuristic and may have false positives. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CodeIntelligenceReport { + /// Dependency cycles between modules/files. + pub cyclic_dependencies: Vec, + /// Functions with no detected callers. + pub dead_code: Vec, + /// Groups of duplicated code blocks. + pub duplication: Vec, +} diff --git a/crates/devpilot-core/src/entities/mod.rs b/crates/devpilot-core/src/entities/mod.rs index a4d6a90..a9b4e89 100644 --- a/crates/devpilot-core/src/entities/mod.rs +++ b/crates/devpilot-core/src/entities/mod.rs @@ -8,6 +8,7 @@ mod analysis; mod ast; mod graph; mod history; +mod intel; mod language; mod llm; mod metadata; @@ -24,6 +25,9 @@ pub use analysis::{ pub use ast::{ClassDef, ExportDecl, ExportKind, FileAst, FunctionDef, ImportDecl, InterfaceDef}; pub use graph::{ArchitectureModel, EdgeKind, Graph, GraphEdge, GraphNode, NodeKind}; pub use history::{AuthorStats, CommitHash, CommitInfo, FileChurn}; +pub use intel::{ + CodeIntelligenceReport, Cycle, DeadSymbol, DuplicationGroup, DuplicationLocation, SearchHit, +}; pub use language::Language; pub use llm::{ChatMessage, ChatRequest, ModelInfo, Role}; pub use metadata::{LanguageStat, ProjectMetadata}; diff --git a/crates/devpilot-core/src/intel.rs b/crates/devpilot-core/src/intel.rs new file mode 100644 index 0000000..205f62f --- /dev/null +++ b/crates/devpilot-core/src/intel.rs @@ -0,0 +1,473 @@ +//! Code-intelligence detectors: pure, deterministic functions over the AST +//! and architecture graphs. +//! +//! - [`find_cycles`] finds dependency cycles (graph SCCs of size ≥ 2, plus +//! self-loops). +//! - [`find_dead_code`] flags functions with no detected callers that are not +//! exported or entry points. Heuristic — dynamic dispatch, trait methods +//! and reflection can cause false positives. +//! - [`find_duplication`] finds identical normalized line blocks across files. +//! - [`search_code`] ranks files and symbols by overlap with a query. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use crate::entities::{ + Cycle, DeadSymbol, DuplicationGroup, DuplicationLocation, EdgeKind, FileAst, Graph, SearchHit, +}; + +// --- Cyclic dependencies ---------------------------------------------- + +/// Finds dependency cycles in a directed graph using Tarjan's SCC algorithm. +/// Returns strongly connected components of size ≥ 2, plus any self-loops. +pub fn find_cycles(graph: &Graph) -> Vec { + let index_of: HashMap<&str, usize> = graph + .nodes + .iter() + .enumerate() + .map(|(i, node)| (node.id.as_str(), i)) + .collect(); + + let mut adjacency: Vec> = vec![Vec::new(); graph.nodes.len()]; + let mut self_loops: BTreeSet = BTreeSet::new(); + for edge in &graph.edges { + if let (Some(&from), Some(&to)) = ( + index_of.get(edge.from.as_str()), + index_of.get(edge.to.as_str()), + ) { + if from == to { + self_loops.insert(from); + } else { + adjacency[from].push(to); + } + } + } + + let mut tarjan = Tarjan::new(&adjacency); + let mut cycles: Vec = tarjan + .run() + .into_iter() + .filter(|component| component.len() >= 2) + .map(|mut component| { + component.sort(); + Cycle { + nodes: component + .into_iter() + .map(|i| graph.nodes[i].id.clone()) + .collect(), + } + }) + .collect(); + + for node in self_loops { + cycles.push(Cycle { + nodes: vec![graph.nodes[node].id.clone()], + }); + } + + cycles.sort_by(|a, b| a.nodes.cmp(&b.nodes)); + cycles +} + +/// Iterative Tarjan strongly-connected-components. +struct Tarjan<'a> { + adjacency: &'a [Vec], + index: usize, + indices: Vec>, + low: Vec, + on_stack: Vec, + stack: Vec, + components: Vec>, +} + +impl<'a> Tarjan<'a> { + fn new(adjacency: &'a [Vec]) -> Self { + let n = adjacency.len(); + Self { + adjacency, + index: 0, + indices: vec![None; n], + low: vec![0; n], + on_stack: vec![false; n], + stack: Vec::new(), + components: Vec::new(), + } + } + + fn run(&mut self) -> Vec> { + for v in 0..self.adjacency.len() { + if self.indices[v].is_none() { + self.strong_connect(v); + } + } + std::mem::take(&mut self.components) + } + + fn strong_connect(&mut self, v: usize) { + self.indices[v] = Some(self.index); + self.low[v] = self.index; + self.index += 1; + self.stack.push(v); + self.on_stack[v] = true; + + for &w in &self.adjacency[v].clone() { + match self.indices[w] { + None => { + self.strong_connect(w); + self.low[v] = self.low[v].min(self.low[w]); + } + Some(w_index) if self.on_stack[w] => { + self.low[v] = self.low[v].min(w_index); + } + _ => {} + } + } + + if self.indices[v] == Some(self.low[v]) { + let mut component = Vec::new(); + while let Some(w) = self.stack.pop() { + self.on_stack[w] = false; + component.push(w); + if w == v { + break; + } + } + self.components.push(component); + } + } +} + +// --- Dead code --------------------------------------------------------- + +/// Flags functions with no incoming call edges that are not exported or named +/// `main`. `call_graph` is the architecture call graph; `asts` supplies +/// exports and definition lines. +pub fn find_dead_code(call_graph: &Graph, asts: &[FileAst]) -> Vec { + // Exported names are considered reachable (public API). + let exported: BTreeSet<&str> = asts + .iter() + .flat_map(|ast| ast.exports.iter().map(|export| export.name.as_str())) + .collect(); + + // Definition line per "file::name" id. + let mut line_of: HashMap = HashMap::new(); + for ast in asts { + let file = ast.path.to_string_lossy().replace('\\', "/"); + for function in &ast.functions { + let id = format!("{file}::{}", function.name); + line_of.insert( + id, + (file.clone(), function.name.clone(), function.start_line), + ); + } + } + + // In-degree from call edges. + let mut incoming: BTreeMap<&str, usize> = BTreeMap::new(); + for node in &call_graph.nodes { + incoming.entry(node.id.as_str()).or_insert(0); + } + for edge in &call_graph.edges { + if edge.kind == EdgeKind::Calls { + *incoming.entry(edge.to.as_str()).or_insert(0) += 1; + } + } + + let mut dead: Vec = incoming + .into_iter() + .filter(|(_, count)| *count == 0) + .filter_map(|(id, _)| line_of.get(id)) + .filter(|(_, name, _)| name != "main" && !exported.contains(name.as_str())) + .map(|(file, name, line)| DeadSymbol { + name: name.clone(), + file: file.clone(), + line: *line, + }) + .collect(); + + dead.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.line.cmp(&b.line))); + dead +} + +// --- Duplication ------------------------------------------------------- + +/// Minimum block size (normalized lines) for duplication detection. +const DUPLICATION_WINDOW: usize = 6; + +/// Finds identical blocks of [`DUPLICATION_WINDOW`] normalized lines across +/// the given files. Blank lines are ignored; each surviving line keeps its +/// original line number. +pub fn find_duplication(files: &[(String, String)]) -> Vec { + // hash of a block -> its locations. + let mut blocks: BTreeMap> = BTreeMap::new(); + + for (file, content) in files { + // (normalized text, original 1-based line number) + let lines: Vec<(String, usize)> = content + .lines() + .enumerate() + .map(|(i, line)| (line.split_whitespace().collect::>().join(" "), i + 1)) + .filter(|(text, _)| !text.is_empty()) + .collect(); + + if lines.len() < DUPLICATION_WINDOW { + continue; + } + for window in lines.windows(DUPLICATION_WINDOW) { + let key = window + .iter() + .map(|(text, _)| text.as_str()) + .collect::>() + .join("\n"); + blocks.entry(key).or_default().push(DuplicationLocation { + file: file.clone(), + start_line: window[0].1, + end_line: window[window.len() - 1].1, + }); + } + } + + let mut groups: Vec = blocks + .into_values() + .filter(|locations| locations.len() >= 2) + .map(|occurrences| DuplicationGroup { + line_count: DUPLICATION_WINDOW, + occurrences, + }) + .collect(); + + groups.sort_by(|a, b| { + b.occurrences + .len() + .cmp(&a.occurrences.len()) + .then_with(|| a.occurrences[0].file.cmp(&b.occurrences[0].file)) + .then_with(|| { + a.occurrences[0] + .start_line + .cmp(&b.occurrences[0].start_line) + }) + }); + groups +} + +// --- Code search ------------------------------------------------------- + +/// Maximum search hits returned. +const MAX_HITS: usize = 30; + +/// Whether a query `word` matches a `haystack`, by substring or by a shared +/// 4-character prefix with one of the haystack's identifier tokens (so +/// "authentication" matches `authenticate` and `auth.rs`). +fn word_matches(word: &str, haystack: &str) -> bool { + let haystack = haystack.to_lowercase(); + if haystack.contains(word) { + return true; + } + let word_prefix: String = word.chars().take(4).collect(); + if word_prefix.len() < 4 { + return false; + } + haystack + .split(|c: char| !c.is_alphanumeric()) + .filter(|token| token.len() >= 4) + .any(|token| token.starts_with(&word_prefix)) +} + +/// Ranks files and their symbols by overlap with `query` words. Symbol hits +/// (functions, classes, interfaces) score higher than path-only hits. +pub fn search_code(query: &str, asts: &[FileAst]) -> Vec { + let words: Vec = query + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| word.len() >= 3) + .map(|word| word.to_lowercase()) + .collect(); + if words.is_empty() { + return Vec::new(); + } + + let count = |haystack: &str| -> usize { + words + .iter() + .filter(|word| word_matches(word, haystack)) + .count() + }; + + let mut hits: Vec = Vec::new(); + for ast in asts { + let path = ast.path.to_string_lossy().replace('\\', "/"); + + for function in &ast.functions { + let score = count(&function.name) * 3 + count(&path); + if score > 0 { + hits.push(SearchHit { + path: path.clone(), + symbol: Some(function.name.clone()), + line: function.start_line, + score, + }); + } + } + for class in &ast.classes { + let score = count(&class.name) * 3 + count(&path); + if score > 0 { + hits.push(SearchHit { + path: path.clone(), + symbol: Some(class.name.clone()), + line: class.start_line, + score, + }); + } + } + for interface in &ast.interfaces { + let score = count(&interface.name) * 3 + count(&path); + if score > 0 { + hits.push(SearchHit { + path: path.clone(), + symbol: Some(interface.name.clone()), + line: interface.start_line, + score, + }); + } + } + + let path_score = count(&path); + if path_score > 0 { + hits.push(SearchHit { + path: path.clone(), + symbol: None, + line: 1, + score: path_score, + }); + } + } + + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.path.cmp(&b.path)) + .then_with(|| a.line.cmp(&b.line)) + }); + hits.truncate(MAX_HITS); + hits +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::{ + ExportDecl, ExportKind, FunctionDef, GraphEdge, GraphNode, Language, NodeKind, + }; + use std::path::PathBuf; + + fn node(id: &str) -> GraphNode { + GraphNode { + id: id.to_string(), + label: id.to_string(), + kind: NodeKind::Module, + } + } + + fn edge(from: &str, to: &str, kind: EdgeKind) -> GraphEdge { + GraphEdge { + from: from.to_string(), + to: to.to_string(), + kind, + } + } + + #[test] + fn find_cycles_detects_a_two_node_cycle() { + let graph = Graph { + nodes: vec![node("a"), node("b"), node("c")], + edges: vec![ + edge("a", "b", EdgeKind::DependsOn), + edge("b", "a", EdgeKind::DependsOn), + edge("b", "c", EdgeKind::DependsOn), + ], + }; + let cycles = find_cycles(&graph); + assert_eq!(cycles.len(), 1); + assert_eq!(cycles[0].nodes, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn find_cycles_ignores_acyclic_graph() { + let graph = Graph { + nodes: vec![node("a"), node("b")], + edges: vec![edge("a", "b", EdgeKind::DependsOn)], + }; + assert!(find_cycles(&graph).is_empty()); + } + + fn function(name: &str, line: usize) -> FunctionDef { + FunctionDef { + name: name.to_string(), + start_line: line, + end_line: line + 1, + is_async: false, + calls: vec![], + } + } + + #[test] + fn find_dead_code_flags_uncalled_private_function() { + let ast = FileAst { + path: PathBuf::from("src/lib.rs"), + language: Language::Rust, + functions: vec![ + function("used", 1), + function("unused", 5), + function("main", 9), + ], + exports: vec![ExportDecl { + name: "used".into(), + kind: ExportKind::Function, + line: 1, + }], + ..FileAst::default() + }; + let call_graph = Graph { + nodes: vec![ + node("src/lib.rs::used"), + node("src/lib.rs::unused"), + node("src/lib.rs::main"), + ], + edges: vec![edge( + "src/lib.rs::main", + "src/lib.rs::used", + EdgeKind::Calls, + )], + }; + + let dead = find_dead_code(&call_graph, &[ast]); + // `used` is exported, `main` is an entry point; only `unused` is dead. + assert_eq!(dead.len(), 1); + assert_eq!(dead[0].name, "unused"); + assert_eq!(dead[0].line, 5); + } + + #[test] + fn find_duplication_detects_repeated_block() { + let block = "let a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\nlet e = 5;\nlet f = 6;\n"; + let files = vec![ + ("a.rs".to_string(), block.to_string()), + ("b.rs".to_string(), format!("// header\n{block}")), + ]; + let groups = find_duplication(&files); + assert!(!groups.is_empty()); + assert_eq!(groups[0].occurrences.len(), 2); + } + + #[test] + fn search_code_ranks_symbol_matches() { + let ast = FileAst { + path: PathBuf::from("src/auth.rs"), + language: Language::Rust, + functions: vec![function("authenticate", 10)], + ..FileAst::default() + }; + let hits = search_code("where is authentication", &[ast]); + assert!(!hits.is_empty()); + assert_eq!(hits[0].symbol.as_deref(), Some("authenticate")); + assert_eq!(hits[0].line, 10); + } +} diff --git a/crates/devpilot-core/src/lib.rs b/crates/devpilot-core/src/lib.rs index 10eecdf..8a2b163 100644 --- a/crates/devpilot-core/src/lib.rs +++ b/crates/devpilot-core/src/lib.rs @@ -21,5 +21,6 @@ pub mod architecture; pub mod chat; pub mod entities; pub mod errors; +pub mod intel; pub mod ports; pub mod usecases; diff --git a/crates/devpilot-core/src/usecases/code_intelligence.rs b/crates/devpilot-core/src/usecases/code_intelligence.rs new file mode 100644 index 0000000..60ede16 --- /dev/null +++ b/crates/devpilot-core/src/usecases/code_intelligence.rs @@ -0,0 +1,117 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use crate::architecture; +use crate::entities::{ + CodeIntelligenceReport, FileAst, FileNode, FileTree, Language, Repository, RepositorySource, + SearchHit, SourceFile, +}; +use crate::errors::GitError; +use crate::intel; +use crate::ports::{CodeAnalyzer, GitReader}; + +/// Computes the deterministic code-intelligence report (cyclic dependencies, +/// dead code, duplication) for a repository. +pub struct AnalyzeCodeIntelligence { + git: Arc, + analyzer: Arc, +} + +impl AnalyzeCodeIntelligence { + /// Creates the use case from its dependencies. + pub fn new(git: Arc, analyzer: Arc) -> Self { + Self { git, analyzer } + } + + /// Opens the project, parses its supported files, and runs the detectors. + pub async fn execute( + &self, + source: RepositorySource, + ) -> Result { + let repository = self.git.open(&source).await?; + let tree = self.git.file_tree(&repository).await?; + let (asts, contents) = parse_repo(&*self.git, &*self.analyzer, &repository, &tree).await; + + let model = architecture::build(&asts, &tree); + + Ok(CodeIntelligenceReport { + cyclic_dependencies: intel::find_cycles(&model.dependency_graph), + dead_code: intel::find_dead_code(&model.call_graph, &asts), + duplication: intel::find_duplication(&contents), + }) + } +} + +/// Searches a repository's symbols and paths for a query. +pub struct SearchCode { + git: Arc, + analyzer: Arc, +} + +impl SearchCode { + /// Creates the use case from its dependencies. + pub fn new(git: Arc, analyzer: Arc) -> Self { + Self { git, analyzer } + } + + /// Opens the project, parses its files, and ranks matches for `query`. + pub async fn execute( + &self, + source: RepositorySource, + query: String, + ) -> Result, GitError> { + let repository = self.git.open(&source).await?; + let tree = self.git.file_tree(&repository).await?; + let (asts, _) = parse_repo(&*self.git, &*self.analyzer, &repository, &tree).await; + Ok(intel::search_code(&query, &asts)) + } +} + +/// Parses every supported file in the tree, returning the ASTs and the raw +/// `(path, content)` pairs (the latter feed the duplication detector). +async fn parse_repo( + git: &dyn GitReader, + analyzer: &dyn CodeAnalyzer, + repository: &Repository, + tree: &FileTree, +) -> (Vec, Vec<(String, String)>) { + let mut asts = Vec::new(); + let mut contents = Vec::new(); + for path in supported_files(tree, analyzer) { + let Ok(content) = git.read_file(repository, &path).await else { + continue; + }; + let file = SourceFile { + path: path.clone(), + content: content.clone(), + }; + if let Ok(ast) = analyzer.parse(&file).await { + contents.push((path.to_string_lossy().replace('\\', "/"), content)); + asts.push(ast); + } + } + (asts, contents) +} + +/// Paths of files whose language the analyzer supports. +fn supported_files(tree: &FileTree, analyzer: &dyn CodeAnalyzer) -> Vec { + let mut paths = Vec::new(); + collect(&tree.root, analyzer, &mut paths); + paths +} + +/// Recursively gathers supported file paths. +fn collect(node: &FileNode, analyzer: &dyn CodeAnalyzer, paths: &mut Vec) { + match node { + FileNode::File { path, .. } => { + if analyzer.detect_language(path) != Language::Unknown { + paths.push(path.clone()); + } + } + FileNode::Directory { children, .. } => { + for child in children { + collect(child, analyzer, paths); + } + } + } +} diff --git a/crates/devpilot-core/src/usecases/mod.rs b/crates/devpilot-core/src/usecases/mod.rs index 44403a6..c19508f 100644 --- a/crates/devpilot-core/src/usecases/mod.rs +++ b/crates/devpilot-core/src/usecases/mod.rs @@ -7,12 +7,14 @@ mod analyze_architecture; mod chat_with_repository; +mod code_intelligence; mod open_project; mod recent_projects; mod scan_repository; pub use analyze_architecture::AnalyzeArchitecture; pub use chat_with_repository::ChatWithRepository; +pub use code_intelligence::{AnalyzeCodeIntelligence, SearchCode}; pub use open_project::OpenProject; pub use recent_projects::{ListRecentProjects, RemoveRecentProject}; pub use scan_repository::ScanRepository; diff --git a/crates/devpilot-testing/tests/usecases.rs b/crates/devpilot-testing/tests/usecases.rs index e633092..56b0320 100644 --- a/crates/devpilot-testing/tests/usecases.rs +++ b/crates/devpilot-testing/tests/usecases.rs @@ -12,8 +12,8 @@ use devpilot_core::entities::{ use devpilot_core::errors::{GitError, ProjectError, RepoScanError, ScanError, StoreError}; use devpilot_core::ports::RecentProjectsStore; use devpilot_core::usecases::{ - AnalyzeArchitecture, ChatWithRepository, ListRecentProjects, OpenProject, RemoveRecentProject, - ScanRepository, + AnalyzeArchitecture, AnalyzeCodeIntelligence, ChatWithRepository, ListRecentProjects, + OpenProject, RemoveRecentProject, ScanRepository, SearchCode, }; use devpilot_testing::fixtures; use devpilot_testing::mocks::{ @@ -266,3 +266,60 @@ async fn chat_with_repository_surfaces_git_error() { assert!(result.is_err()); } + +#[tokio::test] +async fn code_intelligence_finds_dead_code_and_search() { + // lib.rs: orphan (uncalled, private) + used (called by main). main.rs: main -> used. + let lib = FileAst { + path: PathBuf::from("src/lib.rs"), + language: Language::Rust, + functions: vec![ + FunctionDef { + name: "orphan".into(), + start_line: 1, + end_line: 2, + is_async: false, + calls: vec![], + }, + FunctionDef { + name: "used".into(), + start_line: 5, + end_line: 6, + is_async: false, + calls: vec![], + }, + ], + ..FileAst::default() + }; + let main = FileAst { + path: PathBuf::from("src/main.rs"), + language: Language::Rust, + functions: vec![FunctionDef { + name: "main".into(), + start_line: 1, + end_line: 3, + is_async: false, + calls: vec!["used".into()], + }], + ..FileAst::default() + }; + + let git = Arc::new(MockGitReader::new()); + let analyzer = Arc::new(MockCodeAnalyzer::new().with_ast(lib).with_ast(main)); + + let report = AnalyzeCodeIntelligence::new(git.clone(), analyzer.clone()) + .execute(fixtures::sample_local_source()) + .await + .expect("analyze"); + assert!(report.dead_code.iter().any(|d| d.name == "orphan")); + assert!(!report + .dead_code + .iter() + .any(|d| d.name == "used" || d.name == "main")); + + let hits = SearchCode::new(git, analyzer) + .execute(fixtures::sample_local_source(), "where is orphan".into()) + .await + .expect("search"); + assert!(hits.iter().any(|h| h.symbol.as_deref() == Some("orphan"))); +}