diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs index 21f8fe68..a4e64fb0 100644 --- a/crates/codegraph-core/src/lib.rs +++ b/crates/codegraph-core/src/lib.rs @@ -11,6 +11,8 @@ pub mod import_resolution; pub mod incremental; pub mod insert_nodes; pub mod native_db; +pub mod read_queries; +pub mod read_types; pub mod parallel; pub mod parser_registry; pub mod roles_db; diff --git a/crates/codegraph-core/src/native_db.rs b/crates/codegraph-core/src/native_db.rs index df7fe69b..2d5af074 100644 --- a/crates/codegraph-core/src/native_db.rs +++ b/crates/codegraph-core/src/native_db.rs @@ -668,7 +668,7 @@ impl NativeDatabase { impl NativeDatabase { /// Get a reference to the open connection, or error if closed. - fn conn(&self) -> napi::Result<&Connection> { + pub(crate) fn conn(&self) -> napi::Result<&Connection> { self.conn .as_ref() .ok_or_else(|| napi::Error::from_reason("NativeDatabase is closed")) diff --git a/crates/codegraph-core/src/read_queries.rs b/crates/codegraph-core/src/read_queries.rs new file mode 100644 index 00000000..d19bed34 --- /dev/null +++ b/crates/codegraph-core/src/read_queries.rs @@ -0,0 +1,1230 @@ +//! Read query methods on NativeDatabase — implements all 40 Repository read operations. +//! +//! Uses a second `#[napi] impl NativeDatabase` block (Rust allows multiple impl blocks). +//! All methods use `conn.prepare_cached()` for automatic statement caching. + +use std::collections::{HashSet, VecDeque}; + +use napi_derive::napi; +use rusqlite::params; + +use crate::native_db::NativeDatabase; +use crate::read_types::*; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/// Escape LIKE wildcards. Mirrors `escapeLike()` in `src/db/query-builder.ts`. +fn escape_like(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '%' | '_' | '\\' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + out +} + +/// Build test-file exclusion clauses for a column. +fn test_filter_clauses(column: &str) -> String { + format!( + "AND {col} NOT LIKE '%.test.%' \ + AND {col} NOT LIKE '%.spec.%' \ + AND {col} NOT LIKE '%__test__%' \ + AND {col} NOT LIKE '%__tests__%' \ + AND {col} NOT LIKE '%.stories.%'", + col = column, + ) +} + +/// Read a full NativeNodeRow from a rusqlite Row by column name. +fn read_node_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(NativeNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: row.get("end_line")?, + parent_id: row.get("parent_id")?, + exported: row.get("exported")?, + qualified_name: row.get("qualified_name")?, + scope: row.get("scope")?, + visibility: row.get("visibility")?, + role: row.get("role")?, + }) +} + +// ── Constants ─────────────────────────────────────────────────────────── + +const CORE_SYMBOL_KINDS: &[&str] = &[ + "function", + "method", + "class", + "interface", + "type", + "struct", + "enum", + "trait", + "record", + "module", +]; + +const EVERY_SYMBOL_KIND: &[&str] = &[ + "function", + "method", + "class", + "interface", + "type", + "struct", + "enum", + "trait", + "record", + "module", + "parameter", + "property", + "constant", +]; + +const VALID_ROLES: &[&str] = &[ + "entry", + "core", + "utility", + "adapter", + "dead", + "test-only", + "leaf", + "dead-leaf", + "dead-entry", + "dead-ffi", + "dead-unresolved", +]; + +// ── Query Methods ─────────────────────────────────────────────────────── + +#[napi] +impl NativeDatabase { + // ── Batch 1: Counters + Single-Row Lookups ────────────────────────── + + /// Count total nodes. + #[napi] + pub fn count_nodes(&self) -> napi::Result { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT COUNT(*) FROM nodes") + .map_err(|e| napi::Error::from_reason(format!("count_nodes prepare: {e}")))?; + stmt.query_row([], |row| row.get::<_, i32>(0)) + .map_err(|e| napi::Error::from_reason(format!("count_nodes: {e}"))) + } + + /// Count total edges. + #[napi] + pub fn count_edges(&self) -> napi::Result { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT COUNT(*) FROM edges") + .map_err(|e| napi::Error::from_reason(format!("count_edges prepare: {e}")))?; + stmt.query_row([], |row| row.get::<_, i32>(0)) + .map_err(|e| napi::Error::from_reason(format!("count_edges: {e}"))) + } + + /// Count distinct files. + #[napi] + pub fn count_files(&self) -> napi::Result { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT COUNT(DISTINCT file) FROM nodes") + .map_err(|e| napi::Error::from_reason(format!("count_files prepare: {e}")))?; + stmt.query_row([], |row| row.get::<_, i32>(0)) + .map_err(|e| napi::Error::from_reason(format!("count_files: {e}"))) + } + + /// Find a single node by ID. Returns null if not found. + #[napi] + pub fn find_node_by_id(&self, id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT * FROM nodes WHERE id = ?1") + .map_err(|e| napi::Error::from_reason(format!("find_node_by_id prepare: {e}")))?; + match stmt.query_row(params![id], read_node_row) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(napi::Error::from_reason(format!("find_node_by_id: {e}"))), + } + } + + /// Look up a node's ID by (name, kind, file, line). Returns null if not found. + #[napi] + pub fn get_node_id( + &self, + name: String, + kind: String, + file: String, + line: i32, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT id FROM nodes WHERE name = ?1 AND kind = ?2 AND file = ?3 AND line = ?4", + ) + .map_err(|e| napi::Error::from_reason(format!("get_node_id prepare: {e}")))?; + match stmt.query_row(params![name, kind, file, line], |row| row.get::<_, i32>(0)) { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(napi::Error::from_reason(format!("get_node_id: {e}"))), + } + } + + /// Look up a function/method node's ID. + #[napi] + pub fn get_function_node_id( + &self, + name: String, + file: String, + line: i32, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT id FROM nodes WHERE name = ?1 AND kind IN ('function','method') AND file = ?2 AND line = ?3", + ) + .map_err(|e| napi::Error::from_reason(format!("get_function_node_id prepare: {e}")))?; + match stmt.query_row(params![name, file, line], |row| row.get::<_, i32>(0)) { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(napi::Error::from_reason(format!( + "get_function_node_id: {e}" + ))), + } + } + + /// Bulk-fetch node IDs for a file. + #[napi] + pub fn bulk_node_ids_by_file(&self, file: String) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT id, name, kind, line FROM nodes WHERE file = ?1") + .map_err(|e| napi::Error::from_reason(format!("bulk_node_ids_by_file prepare: {e}")))?; + let rows = stmt + .query_map(params![file], |row| { + Ok(NativeNodeIdRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + line: row.get("line")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("bulk_node_ids_by_file: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("bulk_node_ids_by_file collect: {e}"))) + } + + /// Find child nodes of a parent. + #[napi] + pub fn find_node_children(&self, parent_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT name, kind, line, end_line, qualified_name, scope, visibility \ + FROM nodes WHERE parent_id = ?1 ORDER BY line", + ) + .map_err(|e| napi::Error::from_reason(format!("find_node_children prepare: {e}")))?; + let rows = stmt + .query_map(params![parent_id], |row| { + Ok(NativeChildNodeRow { + name: row.get("name")?, + kind: row.get("kind")?, + line: row.get("line")?, + end_line: row.get("end_line")?, + qualified_name: row.get("qualified_name")?, + scope: row.get("scope")?, + visibility: row.get("visibility")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_node_children: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_node_children collect: {e}"))) + } + + // ── Batch 2: Node List Queries ────────────────────────────────────── + + /// Find non-file nodes for a file path, ordered by line. + #[napi] + pub fn find_nodes_by_file(&self, file: String) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT * FROM nodes WHERE file = ?1 AND kind != 'file' ORDER BY line", + ) + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_file prepare: {e}")))?; + let rows = stmt + .query_map(params![file], read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_file: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_file collect: {e}"))) + } + + /// Find file-kind nodes matching a LIKE pattern. + #[napi] + pub fn find_file_nodes(&self, file_like: String) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT * FROM nodes WHERE file LIKE ?1 AND kind = 'file'") + .map_err(|e| napi::Error::from_reason(format!("find_file_nodes prepare: {e}")))?; + let rows = stmt + .query_map(params![file_like], read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_file_nodes: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_file_nodes collect: {e}"))) + } + + /// Find nodes by scope with optional kind and file filters. + #[napi] + pub fn find_nodes_by_scope( + &self, + scope_name: String, + kind: Option, + file: Option, + ) -> napi::Result> { + let conn = self.conn()?; + + let mut sql = "SELECT * FROM nodes WHERE scope = ?1".to_string(); + let mut param_values: Vec> = + vec![Box::new(scope_name)]; + let mut idx = 2; + + if let Some(ref k) = kind { + sql.push_str(&format!(" AND kind = ?{idx}")); + param_values.push(Box::new(k.clone())); + idx += 1; + } + if let Some(ref f) = file { + sql.push_str(&format!(" AND file LIKE ?{idx} ESCAPE '\\'")); + param_values.push(Box::new(format!("%{}%", escape_like(f)))); + } + sql.push_str(" ORDER BY file, line"); + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_scope prepare: {e}")))?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_scope: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_scope collect: {e}"))) + } + + /// Find nodes by qualified name with optional file filter. + #[napi] + pub fn find_node_by_qualified_name( + &self, + qualified_name: String, + file: Option, + ) -> napi::Result> { + let conn = self.conn()?; + + if let Some(ref f) = file { + let pattern = format!("%{}%", escape_like(f)); + let mut stmt = conn + .prepare_cached( + "SELECT * FROM nodes WHERE qualified_name = ?1 AND file LIKE ?2 ESCAPE '\\' ORDER BY file, line", + ) + .map_err(|e| { + napi::Error::from_reason(format!( + "find_node_by_qualified_name prepare: {e}" + )) + })?; + let rows = stmt + .query_map(params![qualified_name, pattern], read_node_row) + .map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) + })?; + rows.collect::, _>>().map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) + }) + } else { + let mut stmt = conn + .prepare_cached( + "SELECT * FROM nodes WHERE qualified_name = ?1 ORDER BY file, line", + ) + .map_err(|e| { + napi::Error::from_reason(format!( + "find_node_by_qualified_name prepare: {e}" + )) + })?; + let rows = stmt + .query_map(params![qualified_name], read_node_row) + .map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) + })?; + rows.collect::, _>>().map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) + }) + } + } + + /// Find nodes matching a name pattern with fan-in count. + #[napi] + pub fn find_nodes_with_fan_in( + &self, + name_pattern: String, + kinds: Option>, + file: Option, + ) -> napi::Result> { + let conn = self.conn()?; + + let mut sql = String::from( + "SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in \ + FROM nodes n \ + LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id) fi ON fi.target_id = n.id \ + WHERE n.name LIKE ?1", + ); + let mut param_values: Vec> = + vec![Box::new(name_pattern)]; + let mut idx = 2; + + if let Some(ref ks) = kinds { + if !ks.is_empty() { + let placeholders: Vec = + ks.iter().enumerate().map(|(i, _)| format!("?{}", idx + i)).collect(); + sql.push_str(&format!(" AND n.kind IN ({})", placeholders.join(", "))); + for k in ks { + param_values.push(Box::new(k.clone())); + } + idx += ks.len(); + } + } + if let Some(ref f) = file { + sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); + param_values.push(Box::new(format!("%{}%", escape_like(f)))); + } + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_with_fan_in prepare: {e}")) + })?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(NativeNodeRowWithFanIn { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: row.get("end_line")?, + parent_id: row.get("parent_id")?, + exported: row.get("exported")?, + qualified_name: row.get("qualified_name")?, + scope: row.get("scope")?, + visibility: row.get("visibility")?, + role: row.get("role")?, + fan_in: row.get("fan_in")?, + }) + }) + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_with_fan_in: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_with_fan_in collect: {e}")) + }) + } + + /// Fetch nodes for triage scoring. + #[napi] + pub fn find_nodes_for_triage( + &self, + kind: Option, + role: Option, + file: Option, + no_tests: Option, + ) -> napi::Result> { + // Validate kind + if let Some(ref k) = kind { + if !EVERY_SYMBOL_KIND.contains(&k.as_str()) { + return Err(napi::Error::from_reason(format!( + "Invalid kind: {k} (expected one of {})", + EVERY_SYMBOL_KIND.join(", ") + ))); + } + } + // Validate role + if let Some(ref r) = role { + if !VALID_ROLES.contains(&r.as_str()) { + return Err(napi::Error::from_reason(format!( + "Invalid role: {r} (expected one of {})", + VALID_ROLES.join(", ") + ))); + } + } + + let conn = self.conn()?; + + let kinds_to_use: Vec<&str> = match kind { + Some(ref k) => vec![k.as_str()], + None => vec!["function", "method", "class"], + }; + let kind_placeholders: Vec = kinds_to_use + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect(); + + let mut sql = format!( + "SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line, \ + n.parent_id, n.exported, n.qualified_name, n.scope, n.visibility, n.role, \ + COALESCE(fi.cnt, 0) AS fan_in, \ + COALESCE(fc.cognitive, 0) AS cognitive, \ + COALESCE(fc.maintainability_index, 0) AS mi, \ + COALESCE(fc.cyclomatic, 0) AS cyclomatic, \ + COALESCE(fc.max_nesting, 0) AS max_nesting, \ + COALESCE(fcc.commit_count, 0) AS churn \ + FROM nodes n \ + LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id) fi ON fi.target_id = n.id \ + LEFT JOIN function_complexity fc ON fc.node_id = n.id \ + LEFT JOIN file_commit_counts fcc ON n.file = fcc.file \ + WHERE n.kind IN ({kinds})", + kinds = kind_placeholders.join(", "), + ); + + let mut param_values: Vec> = Vec::new(); + for k in &kinds_to_use { + param_values.push(Box::new(k.to_string())); + } + let mut idx = kinds_to_use.len() + 1; + + if no_tests.unwrap_or(false) { + sql.push_str(&format!(" {}", test_filter_clauses("n.file"))); + } + if let Some(ref f) = file { + sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); + param_values.push(Box::new(format!("%{}%", escape_like(f)))); + idx += 1; + } + if let Some(ref r) = role { + if r == "dead" { + sql.push_str(&format!(" AND n.role LIKE ?{idx}")); + param_values.push(Box::new("dead%".to_string())); + } else { + sql.push_str(&format!(" AND n.role = ?{idx}")); + param_values.push(Box::new(r.clone())); + } + } + sql.push_str(" ORDER BY n.file, n.line"); + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_for_triage prepare: {e}")) + })?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(NativeTriageNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: row.get("end_line")?, + parent_id: row.get("parent_id")?, + exported: row.get("exported")?, + qualified_name: row.get("qualified_name")?, + scope: row.get("scope")?, + visibility: row.get("visibility")?, + role: row.get("role")?, + fan_in: row.get("fan_in")?, + cognitive: row.get("cognitive")?, + mi: row.get("mi")?, + cyclomatic: row.get("cyclomatic")?, + max_nesting: row.get("max_nesting")?, + churn: row.get("churn")?, + }) + }) + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_for_triage: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_nodes_for_triage collect: {e}")) + }) + } + + /// List function/method/class nodes. + #[napi] + pub fn list_function_nodes( + &self, + file: Option, + pattern: Option, + no_tests: Option, + ) -> napi::Result> { + self.query_function_nodes(file, pattern, no_tests) + } + + /// Same as list_function_nodes (TS wraps result as iterator). + #[napi] + pub fn iterate_function_nodes( + &self, + file: Option, + pattern: Option, + no_tests: Option, + ) -> napi::Result> { + self.query_function_nodes(file, pattern, no_tests) + } + + // ── Batch 3: Edge Queries ─────────────────────────────────────────── + + /// Find all callees of a node (outgoing 'calls' edges). + #[napi] + pub fn find_callees(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line, n.end_line \ + FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1 AND e.kind = 'calls'", + ) + .map_err(|e| napi::Error::from_reason(format!("find_callees prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeRelatedNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: row.get("end_line")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_callees: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_callees collect: {e}"))) + } + + /// Find all callers of a node (incoming 'calls' edges). + #[napi] + pub fn find_callers(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.id, n.name, n.kind, n.file, n.line \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind = 'calls'", + ) + .map_err(|e| napi::Error::from_reason(format!("find_callers prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeRelatedNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: None, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_callers: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_callers collect: {e}"))) + } + + /// Find distinct callers of a node. + #[napi] + pub fn find_distinct_callers(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind = 'calls'", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_distinct_callers prepare: {e}")) + })?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeRelatedNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: None, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_distinct_callers: {e}")))?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_distinct_callers collect: {e}")) + }) + } + + /// Find all outgoing edges with edge kind. + #[napi] + pub fn find_all_outgoing_edges( + &self, + node_id: i32, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.name, n.kind, n.file, n.line, e.kind AS edge_kind \ + FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_all_outgoing_edges prepare: {e}")) + })?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeAdjacentEdgeRow { + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + edge_kind: row.get("edge_kind")?, + }) + }) + .map_err(|e| { + napi::Error::from_reason(format!("find_all_outgoing_edges: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_all_outgoing_edges collect: {e}")) + }) + } + + /// Find all incoming edges with edge kind. + #[napi] + pub fn find_all_incoming_edges( + &self, + node_id: i32, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.name, n.kind, n.file, n.line, e.kind AS edge_kind \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_all_incoming_edges prepare: {e}")) + })?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeAdjacentEdgeRow { + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + edge_kind: row.get("edge_kind")?, + }) + }) + .map_err(|e| { + napi::Error::from_reason(format!("find_all_incoming_edges: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_all_incoming_edges collect: {e}")) + }) + } + + /// Get distinct callee names for a node. + #[napi] + pub fn find_callee_names(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.name \ + FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1 AND e.kind = 'calls' \ + ORDER BY n.name", + ) + .map_err(|e| napi::Error::from_reason(format!("find_callee_names prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| row.get::<_, String>(0)) + .map_err(|e| napi::Error::from_reason(format!("find_callee_names: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_callee_names collect: {e}"))) + } + + /// Get distinct caller names for a node. + #[napi] + pub fn find_caller_names(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.name \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind = 'calls' \ + ORDER BY n.name", + ) + .map_err(|e| napi::Error::from_reason(format!("find_caller_names prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| row.get::<_, String>(0)) + .map_err(|e| napi::Error::from_reason(format!("find_caller_names: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_caller_names collect: {e}"))) + } + + /// Find outgoing import edges. + #[napi] + pub fn find_import_targets(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.file, e.kind AS edge_kind \ + FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1 AND e.kind IN ('imports', 'imports-type')", + ) + .map_err(|e| napi::Error::from_reason(format!("find_import_targets prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeImportEdgeRow { + file: row.get("file")?, + edge_kind: row.get("edge_kind")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_import_targets: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_import_targets collect: {e}"))) + } + + /// Find incoming import edges. + #[napi] + pub fn find_import_sources(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.file, e.kind AS edge_kind \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind IN ('imports', 'imports-type')", + ) + .map_err(|e| napi::Error::from_reason(format!("find_import_sources prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeImportEdgeRow { + file: row.get("file")?, + edge_kind: row.get("edge_kind")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_import_sources: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_import_sources collect: {e}"))) + } + + /// Find nodes that import a given node. + #[napi] + pub fn find_import_dependents(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT n.* FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind IN ('imports', 'imports-type')", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_import_dependents prepare: {e}")) + })?; + let rows = stmt + .query_map(params![node_id], read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_import_dependents: {e}")))?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_import_dependents collect: {e}")) + }) + } + + /// Get IDs of symbols in a file called from other files. + #[napi] + pub fn find_cross_file_call_targets(&self, file: String) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT e.target_id FROM edges e \ + JOIN nodes caller ON e.source_id = caller.id \ + JOIN nodes target ON e.target_id = target.id \ + WHERE target.file = ?1 AND caller.file != ?2 AND e.kind = 'calls'", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_cross_file_call_targets prepare: {e}")) + })?; + let rows = stmt + .query_map(params![file, file], |row| row.get::<_, i32>(0)) + .map_err(|e| { + napi::Error::from_reason(format!("find_cross_file_call_targets: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_cross_file_call_targets collect: {e}")) + }) + } + + /// Count callers in a different file than the target. + #[napi] + pub fn count_cross_file_callers(&self, node_id: i32, file: String) -> napi::Result { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT COUNT(*) FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind = 'calls' AND n.file != ?2", + ) + .map_err(|e| { + napi::Error::from_reason(format!("count_cross_file_callers prepare: {e}")) + })?; + stmt.query_row(params![node_id, file], |row| row.get::<_, i32>(0)) + .map_err(|e| napi::Error::from_reason(format!("count_cross_file_callers: {e}"))) + } + + /// Get all ancestor class IDs via extends edges (BFS). + #[napi] + pub fn get_class_hierarchy(&self, class_node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut ancestors = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(class_node_id); + + let mut stmt = conn + .prepare_cached( + "SELECT n.id FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1 AND e.kind = 'extends'", + ) + .map_err(|e| { + napi::Error::from_reason(format!("get_class_hierarchy prepare: {e}")) + })?; + + while let Some(current) = queue.pop_front() { + let parents: Vec = stmt + .query_map(params![current], |row| row.get::<_, i32>(0)) + .map_err(|e| { + napi::Error::from_reason(format!("get_class_hierarchy query: {e}")) + })? + .filter_map(|r| r.ok()) + .collect(); + for p in parents { + if ancestors.insert(p) { + queue.push_back(p); + } + } + } + Ok(ancestors.into_iter().collect()) + } + + /// Find implementors of an interface/trait. + #[napi] + pub fn find_implementors(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line \ + FROM edges e JOIN nodes n ON e.source_id = n.id \ + WHERE e.target_id = ?1 AND e.kind = 'implements'", + ) + .map_err(|e| napi::Error::from_reason(format!("find_implementors prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeRelatedNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: None, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_implementors: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_implementors collect: {e}"))) + } + + /// Find interfaces/traits that a class/struct implements. + #[napi] + pub fn find_interfaces(&self, node_id: i32) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line \ + FROM edges e JOIN nodes n ON e.target_id = n.id \ + WHERE e.source_id = ?1 AND e.kind = 'implements'", + ) + .map_err(|e| napi::Error::from_reason(format!("find_interfaces prepare: {e}")))?; + let rows = stmt + .query_map(params![node_id], |row| { + Ok(NativeRelatedNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + line: row.get("line")?, + end_line: None, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("find_interfaces: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("find_interfaces collect: {e}"))) + } + + /// Find intra-file call edges. + #[napi] + pub fn find_intra_file_call_edges( + &self, + file: String, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT caller.name AS caller_name, callee.name AS callee_name \ + FROM edges e \ + JOIN nodes caller ON e.source_id = caller.id \ + JOIN nodes callee ON e.target_id = callee.id \ + WHERE caller.file = ?1 AND callee.file = ?2 AND e.kind = 'calls' \ + ORDER BY caller.line", + ) + .map_err(|e| { + napi::Error::from_reason(format!("find_intra_file_call_edges prepare: {e}")) + })?; + let rows = stmt + .query_map(params![file, file], |row| { + Ok(NativeIntraFileCallEdge { + caller_name: row.get("caller_name")?, + callee_name: row.get("callee_name")?, + }) + }) + .map_err(|e| { + napi::Error::from_reason(format!("find_intra_file_call_edges: {e}")) + })?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("find_intra_file_call_edges collect: {e}")) + }) + } + + // ── Batch 4: Graph-Read + Table Checks ────────────────────────────── + + /// Get callable nodes (all core symbol kinds). + #[napi] + pub fn get_callable_nodes(&self) -> napi::Result> { + let conn = self.conn()?; + // Build static IN clause from CORE_SYMBOL_KINDS + let kinds_sql: String = CORE_SYMBOL_KINDS + .iter() + .map(|k| format!("'{k}'")) + .collect::>() + .join(","); + let sql = format!( + "SELECT id, name, kind, file FROM nodes WHERE kind IN ({kinds_sql})" + ); + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_callable_nodes prepare: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(NativeCallableNodeRow { + id: row.get("id")?, + name: row.get("name")?, + kind: row.get("kind")?, + file: row.get("file")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("get_callable_nodes: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_callable_nodes collect: {e}"))) + } + + /// Get all 'calls' edges. + #[napi] + pub fn get_call_edges(&self) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls'", + ) + .map_err(|e| napi::Error::from_reason(format!("get_call_edges prepare: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(NativeCallEdgeRow { + source_id: row.get("source_id")?, + target_id: row.get("target_id")?, + confidence: row.get("confidence")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("get_call_edges: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_call_edges collect: {e}"))) + } + + /// Get all file-kind nodes. + #[napi] + pub fn get_file_nodes_all(&self) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached("SELECT id, name, file FROM nodes WHERE kind = 'file'") + .map_err(|e| napi::Error::from_reason(format!("get_file_nodes_all prepare: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(NativeFileNodeRow { + id: row.get("id")?, + name: row.get("name")?, + file: row.get("file")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("get_file_nodes_all: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_file_nodes_all collect: {e}"))) + } + + /// Get all import edges. + #[napi] + pub fn get_import_edges(&self) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type')", + ) + .map_err(|e| napi::Error::from_reason(format!("get_import_edges prepare: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(NativeImportGraphEdgeRow { + source_id: row.get("source_id")?, + target_id: row.get("target_id")?, + }) + }) + .map_err(|e| napi::Error::from_reason(format!("get_import_edges: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_import_edges collect: {e}"))) + } + + /// Check whether CFG tables exist. + #[napi] + pub fn has_cfg_tables(&self) -> napi::Result { + let conn = self.conn()?; + match conn.prepare("SELECT 1 FROM cfg_blocks LIMIT 0") { + Ok(_) => Ok(true), + Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(false), + Err(e) => Err(napi::Error::from_reason(format!("has_cfg_tables: {e}"))), + } + } + + /// Check whether embeddings table has data. + #[napi] + pub fn has_embeddings(&self) -> napi::Result { + let conn = self.conn()?; + match conn + .prepare("SELECT 1 FROM embeddings LIMIT 1") + .and_then(|mut stmt| stmt.query_row([], |_| Ok(()))) + { + Ok(()) => Ok(true), + Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(false), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(napi::Error::from_reason(format!("has_embeddings: {e}"))), + } + } + + /// Check whether dataflow table exists and has data. + #[napi] + pub fn has_dataflow_table(&self) -> napi::Result { + let conn = self.conn()?; + match conn + .prepare("SELECT COUNT(*) FROM dataflow") + .and_then(|mut stmt| stmt.query_row([], |row| row.get::<_, i32>(0))) + { + Ok(c) => Ok(c > 0), + Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(false), + Err(e) => Err(napi::Error::from_reason(format!("has_dataflow_table: {e}"))), + } + } + + /// Get complexity metrics for a node. + #[napi] + pub fn get_complexity_for_node( + &self, + node_id: i32, + ) -> napi::Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare_cached( + "SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume \ + FROM function_complexity WHERE node_id = ?1", + ) + .map_err(|e| { + napi::Error::from_reason(format!("get_complexity_for_node prepare: {e}")) + })?; + match stmt.query_row(params![node_id], |row| { + Ok(NativeComplexityMetrics { + cognitive: row.get("cognitive")?, + cyclomatic: row.get("cyclomatic")?, + max_nesting: row.get("max_nesting")?, + maintainability_index: row.get("maintainability_index")?, + halstead_volume: row.get("halstead_volume")?, + }) + }) { + Ok(m) => Ok(Some(m)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(napi::Error::from_reason(format!( + "get_complexity_for_node: {e}" + ))), + } + } +} + +// ── Private helper methods ────────────────────────────────────────────── + +impl NativeDatabase { + /// Shared implementation for list_function_nodes / iterate_function_nodes. + fn query_function_nodes( + &self, + file: Option, + pattern: Option, + no_tests: Option, + ) -> napi::Result> { + let conn = self.conn()?; + + let mut sql = String::from( + "SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line, \ + n.parent_id, n.exported, n.qualified_name, n.scope, n.visibility, n.role \ + FROM nodes n \ + WHERE n.kind IN ('function', 'method', 'class')", + ); + let mut param_values: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(ref f) = file { + sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); + param_values.push(Box::new(format!("%{}%", escape_like(f)))); + idx += 1; + } + if let Some(ref p) = pattern { + sql.push_str(&format!(" AND n.name LIKE ?{idx} ESCAPE '\\'")); + param_values.push(Box::new(format!("%{}%", escape_like(p)))); + idx += 1; + } + let _ = idx; // suppress unused warning + if no_tests.unwrap_or(false) { + sql.push_str(&format!(" {}", test_filter_clauses("n.file"))); + } + sql.push_str(" ORDER BY n.file, n.line"); + + let mut stmt = conn + .prepare_cached(&sql) + .map_err(|e| { + napi::Error::from_reason(format!("query_function_nodes prepare: {e}")) + })?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), read_node_row) + .map_err(|e| napi::Error::from_reason(format!("query_function_nodes: {e}")))?; + rows.collect::, _>>() + .map_err(|e| { + napi::Error::from_reason(format!("query_function_nodes collect: {e}")) + }) + } +} diff --git a/crates/codegraph-core/src/read_types.rs b/crates/codegraph-core/src/read_types.rs new file mode 100644 index 00000000..cf8028f0 --- /dev/null +++ b/crates/codegraph-core/src/read_types.rs @@ -0,0 +1,177 @@ +//! Return-type structs for NativeDatabase read queries. +//! +//! Each struct maps to a TypeScript row type used by the Repository interface. +//! All structs derive `#[napi(object)]` for automatic JS serialization. + +use napi_derive::napi; + +/// Full node row — mirrors `NodeRow` in `src/types.ts`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeNodeRow { + pub id: i32, + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub end_line: Option, + pub parent_id: Option, + pub exported: Option, + pub qualified_name: Option, + pub scope: Option, + pub visibility: Option, + pub role: Option, +} + +/// Node row with fan-in count — mirrors `NodeRowWithFanIn`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeNodeRowWithFanIn { + pub id: i32, + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub end_line: Option, + pub parent_id: Option, + pub exported: Option, + pub qualified_name: Option, + pub scope: Option, + pub visibility: Option, + pub role: Option, + pub fan_in: i32, +} + +/// Triage node row — mirrors `TriageNodeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeTriageNodeRow { + pub id: i32, + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub end_line: Option, + pub parent_id: Option, + pub exported: Option, + pub qualified_name: Option, + pub scope: Option, + pub visibility: Option, + pub role: Option, + pub fan_in: i32, + pub cognitive: i32, + pub mi: f64, + pub cyclomatic: i32, + pub max_nesting: i32, + pub churn: i32, +} + +/// Minimal node ID row — mirrors `NodeIdRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeNodeIdRow { + pub id: i32, + pub name: String, + pub kind: String, + pub line: Option, +} + +/// Child node row — mirrors `ChildNodeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeChildNodeRow { + pub name: String, + pub kind: String, + pub line: Option, + pub end_line: Option, + pub qualified_name: Option, + pub scope: Option, + pub visibility: Option, +} + +/// Related node row (callers/callees) — mirrors `RelatedNodeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeRelatedNodeRow { + pub id: i32, + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub end_line: Option, +} + +/// Adjacent edge row — mirrors `AdjacentEdgeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeAdjacentEdgeRow { + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub edge_kind: String, +} + +/// Import edge row — mirrors `ImportEdgeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeImportEdgeRow { + pub file: String, + pub edge_kind: String, +} + +/// Intra-file call edge — mirrors `IntraFileCallEdge`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeIntraFileCallEdge { + pub caller_name: String, + pub callee_name: String, +} + +/// Callable node row (for graph construction) — mirrors `CallableNodeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeCallableNodeRow { + pub id: i32, + pub name: String, + pub kind: String, + pub file: String, +} + +/// Call edge row — mirrors `CallEdgeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeCallEdgeRow { + pub source_id: i32, + pub target_id: i32, + pub confidence: Option, +} + +/// File node row — mirrors `FileNodeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeFileNodeRow { + pub id: i32, + pub name: String, + pub file: String, +} + +/// Import graph edge row — mirrors `ImportGraphEdgeRow`. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeImportGraphEdgeRow { + pub source_id: i32, + pub target_id: i32, +} + +/// Complexity metrics — mirrors `ComplexityMetrics` from Repository. +/// Named differently from the extractor-level ComplexityMetrics in types.rs. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeComplexityMetrics { + pub cognitive: i32, + pub cyclomatic: i32, + pub max_nesting: i32, + pub maintainability_index: Option, + pub halstead_volume: Option, +} diff --git a/src/db/connection.ts b/src/db/connection.ts index c504887e..78d29480 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -4,9 +4,11 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import { debug, warn } from '../infrastructure/logger.js'; +import { getNative, isNativeAvailable } from '../infrastructure/native.js'; import { DbError } from '../shared/errors.js'; import type { BetterSqlite3Database } from '../types.js'; import { Repository } from './repository/base.js'; +import { NativeRepository } from './repository/native-repository.js'; import { SqliteRepository } from './repository/sqlite-repository.js'; /** Lazy-loaded package version (read once from package.json). */ @@ -286,7 +288,9 @@ export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database { * Open a Repository from either an injected instance or a DB path. * * When `opts.repo` is a Repository instance, returns it directly (no DB opened). - * Otherwise opens a readonly SQLite DB and wraps it in SqliteRepository. + * When the native engine is available, opens a NativeDatabase (rusqlite) and + * wraps it in NativeRepository. Otherwise falls back to better-sqlite3 via + * SqliteRepository. */ export function openRepo( customDbPath?: string, @@ -300,6 +304,56 @@ export function openRepo( } return { repo: opts.repo, close() {} }; } + + // Try native rusqlite path first (Phase 6.14) + if (isNativeAvailable()) { + try { + const dbPath = findDbPath(customDbPath); + if (!fs.existsSync(dbPath)) { + throw new DbError( + `No codegraph database found at ${dbPath}.\nRun "codegraph build" first to analyze your codebase.`, + { file: dbPath }, + ); + } + const native = getNative(); + const ndb = native.NativeDatabase.openReadonly(dbPath); + try { + // Version check (same logic as openReadonlyOrFail) + if (!_versionWarned) { + try { + const buildVersion = ndb.getBuildMeta('codegraph_version'); + const currentVersion = getPackageVersion(); + if (buildVersion && currentVersion && buildVersion !== currentVersion) { + warn( + `DB was built with codegraph v${buildVersion}, running v${currentVersion}. Consider: codegraph build --no-incremental`, + ); + } + } catch { + // build_meta table may not exist in older DBs + } + _versionWarned = true; + } + + return { + repo: new NativeRepository(ndb), + close() { + ndb.close(); + }, + }; + } catch (innerErr) { + ndb.close(); + throw innerErr; + } + } catch (e) { + // Re-throw user-visible errors (e.g. DB not found) — only silently + // fall back for native-engine failures (e.g. incompatible native binary). + if (e instanceof DbError) throw e; + debug( + `openRepo: native path failed, falling back to better-sqlite3: ${(e as Error).message}`, + ); + } + } + const db = openReadonlyOrFail(customDbPath); return { repo: new SqliteRepository(db), diff --git a/src/db/repository/index.ts b/src/db/repository/index.ts index 94a8cfab..1f59c135 100644 --- a/src/db/repository/index.ts +++ b/src/db/repository/index.ts @@ -28,6 +28,7 @@ export { export { getEmbeddingCount, getEmbeddingMeta, hasEmbeddings } from './embeddings.js'; export { getCallableNodes, getCallEdges, getFileNodesAll, getImportEdges } from './graph-read.js'; export { InMemoryRepository } from './in-memory-repository.js'; +export { NativeRepository } from './native-repository.js'; export { bulkNodeIdsByFile, countEdges, diff --git a/src/db/repository/native-repository.ts b/src/db/repository/native-repository.ts new file mode 100644 index 00000000..669f73fe --- /dev/null +++ b/src/db/repository/native-repository.ts @@ -0,0 +1,361 @@ +/** + * NativeRepository — delegates all Repository read methods to NativeDatabase (rusqlite via napi-rs). + * + * Phase 6.14: every query runs via rusqlite when the native engine is available. + * Falls back to SqliteRepository (better-sqlite3) when native is unavailable. + * + * napi-rs converts Rust snake_case fields to JS camelCase. This class maps them + * back to the snake_case field names that the Repository interface expects. + */ + +import { ConfigError } from '../../shared/errors.js'; +import type { + AdjacentEdgeRow, + CallableNodeRow, + CallEdgeRow, + ChildNodeRow, + ComplexityMetrics, + FileNodeRow, + ImportEdgeRow, + ImportGraphEdgeRow, + IntraFileCallEdge, + ListFunctionOpts, + NativeAdjacentEdgeRow, + NativeCallableNodeRow, + NativeCallEdgeRow, + NativeChildNodeRow, + NativeComplexityMetrics, + NativeDatabase, + NativeFileNodeRow, + NativeImportEdgeRow, + NativeImportGraphEdgeRow, + NativeIntraFileCallEdge, + NativeNodeIdRow, + NativeNodeRow, + NativeNodeRowWithFanIn, + NativeRelatedNodeRow, + NativeTriageNodeRow, + NodeIdRow, + NodeRow, + NodeRowWithFanIn, + QueryOpts, + RelatedNodeRow, + TriageNodeRow, + TriageQueryOpts, +} from '../../types.js'; +import { Repository } from './base.js'; + +// ── Row converters (napi camelCase → Repository snake_case) ───────────── + +function toNodeRow(r: NativeNodeRow): NodeRow { + return { + id: r.id, + name: r.name, + kind: r.kind as NodeRow['kind'], + file: r.file, + line: r.line ?? 0, + end_line: r.endLine ?? null, + parent_id: r.parentId ?? null, + exported: (r.exported ?? null) as 0 | 1 | null, + qualified_name: r.qualifiedName ?? null, + scope: r.scope ?? null, + visibility: (r.visibility ?? null) as NodeRow['visibility'], + role: (r.role ?? null) as NodeRow['role'], + }; +} + +function toNodeRowWithFanIn(r: NativeNodeRowWithFanIn): NodeRowWithFanIn { + return { ...toNodeRow(r), fan_in: r.fanIn }; +} + +function toTriageNodeRow(r: NativeTriageNodeRow): TriageNodeRow { + return { + ...toNodeRow(r), + fan_in: r.fanIn, + cognitive: r.cognitive, + mi: r.mi, + cyclomatic: r.cyclomatic, + max_nesting: r.maxNesting, + churn: r.churn, + }; +} + +function toNodeIdRow(r: NativeNodeIdRow): NodeIdRow { + return { id: r.id, name: r.name, kind: r.kind, line: r.line ?? 0 }; +} + +function toChildNodeRow(r: NativeChildNodeRow): ChildNodeRow { + return { + name: r.name, + kind: r.kind as ChildNodeRow['kind'], + line: r.line ?? 0, + end_line: r.endLine ?? null, + qualified_name: r.qualifiedName ?? null, + scope: r.scope ?? null, + visibility: (r.visibility ?? null) as ChildNodeRow['visibility'], + }; +} + +function toRelatedNodeRow(r: NativeRelatedNodeRow): RelatedNodeRow { + return { + id: r.id, + name: r.name, + kind: r.kind, + file: r.file, + line: r.line ?? 0, + end_line: r.endLine, + }; +} + +function toAdjacentEdgeRow(r: NativeAdjacentEdgeRow): AdjacentEdgeRow { + return { + name: r.name, + kind: r.kind, + file: r.file, + line: r.line ?? 0, + edge_kind: r.edgeKind as AdjacentEdgeRow['edge_kind'], + }; +} + +function toImportEdgeRow(r: NativeImportEdgeRow): ImportEdgeRow { + return { file: r.file, edge_kind: r.edgeKind as ImportEdgeRow['edge_kind'] }; +} + +function toIntraFileCallEdge(r: NativeIntraFileCallEdge): IntraFileCallEdge { + return { caller_name: r.callerName, callee_name: r.calleeName }; +} + +function toCallableNodeRow(r: NativeCallableNodeRow): CallableNodeRow { + return { id: r.id, name: r.name, kind: r.kind, file: r.file }; +} + +function toCallEdgeRow(r: NativeCallEdgeRow): CallEdgeRow { + return { + source_id: r.sourceId, + target_id: r.targetId, + confidence: r.confidence, + }; +} + +function toFileNodeRow(r: NativeFileNodeRow): FileNodeRow { + return { id: r.id, name: r.name, file: r.file }; +} + +function toImportGraphEdgeRow(r: NativeImportGraphEdgeRow): ImportGraphEdgeRow { + return { source_id: r.sourceId, target_id: r.targetId }; +} + +function toComplexityMetrics(r: NativeComplexityMetrics): ComplexityMetrics { + return { + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + max_nesting: r.maxNesting, + maintainability_index: r.maintainabilityIndex ?? null, + halstead_volume: r.halsteadVolume ?? null, + }; +} + +// ── NativeRepository ──────────────────────────────────────────────────── + +export class NativeRepository extends Repository { + #ndb: NativeDatabase; + + constructor(ndb: NativeDatabase) { + super(); + this.#ndb = ndb; + } + + // ── Node lookups ────────────────────────────────────────────────── + + findNodeById(id: number): NodeRow | undefined { + const r = this.#ndb.findNodeById(id); + return r ? toNodeRow(r) : undefined; + } + + findNodesByFile(file: string): NodeRow[] { + return this.#ndb.findNodesByFile(file).map(toNodeRow); + } + + findFileNodes(fileLike: string): NodeRow[] { + return this.#ndb.findFileNodes(fileLike).map(toNodeRow); + } + + findNodesWithFanIn(namePattern: string, opts: QueryOpts = {}): NodeRowWithFanIn[] { + return this.#ndb + .findNodesWithFanIn(namePattern, opts.kinds ?? null, opts.file ?? null) + .map(toNodeRowWithFanIn); + } + + countNodes(): number { + return this.#ndb.countNodes(); + } + + countEdges(): number { + return this.#ndb.countEdges(); + } + + countFiles(): number { + return this.#ndb.countFiles(); + } + + getNodeId(name: string, kind: string, file: string, line: number): number | undefined { + return this.#ndb.getNodeId(name, kind, file, line) ?? undefined; + } + + getFunctionNodeId(name: string, file: string, line: number): number | undefined { + return this.#ndb.getFunctionNodeId(name, file, line) ?? undefined; + } + + bulkNodeIdsByFile(file: string): NodeIdRow[] { + return this.#ndb.bulkNodeIdsByFile(file).map(toNodeIdRow); + } + + findNodeChildren(parentId: number): ChildNodeRow[] { + return this.#ndb.findNodeChildren(parentId).map(toChildNodeRow); + } + + findNodesByScope(scopeName: string, opts: QueryOpts = {}): NodeRow[] { + return this.#ndb + .findNodesByScope(scopeName, opts.kind ?? null, opts.file ?? null) + .map(toNodeRow); + } + + findNodeByQualifiedName(qualifiedName: string, opts: { file?: string } = {}): NodeRow[] { + return this.#ndb.findNodeByQualifiedName(qualifiedName, opts.file ?? null).map(toNodeRow); + } + + listFunctionNodes(opts: ListFunctionOpts = {}): NodeRow[] { + return this.#ndb + .listFunctionNodes(opts.file ?? null, opts.pattern ?? null, opts.noTests ?? null) + .map(toNodeRow); + } + + iterateFunctionNodes(opts: ListFunctionOpts = {}): IterableIterator { + const rows = this.#ndb + .iterateFunctionNodes(opts.file ?? null, opts.pattern ?? null, opts.noTests ?? null) + .map(toNodeRow); + return rows[Symbol.iterator](); + } + + findNodesForTriage(opts: TriageQueryOpts = {}): TriageNodeRow[] { + try { + return this.#ndb + .findNodesForTriage( + opts.kind ?? null, + opts.role ?? null, + opts.file ?? null, + opts.noTests ?? null, + ) + .map(toTriageNodeRow); + } catch (e: unknown) { + const msg = (e as Error).message; + if (msg.startsWith('Invalid kind:') || msg.startsWith('Invalid role:')) { + throw new ConfigError(msg); + } + throw e; + } + } + + // ── Edge queries ────────────────────────────────────────────────── + + findCallees(nodeId: number): RelatedNodeRow[] { + return this.#ndb.findCallees(nodeId).map(toRelatedNodeRow); + } + + findCallers(nodeId: number): RelatedNodeRow[] { + return this.#ndb.findCallers(nodeId).map(toRelatedNodeRow); + } + + findDistinctCallers(nodeId: number): RelatedNodeRow[] { + return this.#ndb.findDistinctCallers(nodeId).map(toRelatedNodeRow); + } + + findAllOutgoingEdges(nodeId: number): AdjacentEdgeRow[] { + return this.#ndb.findAllOutgoingEdges(nodeId).map(toAdjacentEdgeRow); + } + + findAllIncomingEdges(nodeId: number): AdjacentEdgeRow[] { + return this.#ndb.findAllIncomingEdges(nodeId).map(toAdjacentEdgeRow); + } + + findCalleeNames(nodeId: number): string[] { + return this.#ndb.findCalleeNames(nodeId); + } + + findCallerNames(nodeId: number): string[] { + return this.#ndb.findCallerNames(nodeId); + } + + findImportTargets(nodeId: number): ImportEdgeRow[] { + return this.#ndb.findImportTargets(nodeId).map(toImportEdgeRow); + } + + findImportSources(nodeId: number): ImportEdgeRow[] { + return this.#ndb.findImportSources(nodeId).map(toImportEdgeRow); + } + + findImportDependents(nodeId: number): NodeRow[] { + return this.#ndb.findImportDependents(nodeId).map(toNodeRow); + } + + findCrossFileCallTargets(file: string): Set { + return new Set(this.#ndb.findCrossFileCallTargets(file)); + } + + countCrossFileCallers(nodeId: number, file: string): number { + return this.#ndb.countCrossFileCallers(nodeId, file); + } + + getClassHierarchy(classNodeId: number): Set { + return new Set(this.#ndb.getClassHierarchy(classNodeId)); + } + + findImplementors(nodeId: number): RelatedNodeRow[] { + return this.#ndb.findImplementors(nodeId).map(toRelatedNodeRow); + } + + findInterfaces(nodeId: number): RelatedNodeRow[] { + return this.#ndb.findInterfaces(nodeId).map(toRelatedNodeRow); + } + + findIntraFileCallEdges(file: string): IntraFileCallEdge[] { + return this.#ndb.findIntraFileCallEdges(file).map(toIntraFileCallEdge); + } + + // ── Graph-read queries ──────────────────────────────────────────── + + getCallableNodes(): CallableNodeRow[] { + return this.#ndb.getCallableNodes().map(toCallableNodeRow); + } + + getCallEdges(): CallEdgeRow[] { + return this.#ndb.getCallEdges().map(toCallEdgeRow); + } + + getFileNodesAll(): FileNodeRow[] { + return this.#ndb.getFileNodesAll().map(toFileNodeRow); + } + + getImportEdges(): ImportGraphEdgeRow[] { + return this.#ndb.getImportEdges().map(toImportGraphEdgeRow); + } + + // ── Optional table checks ───────────────────────────────────────── + + hasCfgTables(): boolean { + return this.#ndb.hasCfgTables(); + } + + hasEmbeddings(): boolean { + return this.#ndb.hasEmbeddings(); + } + + hasDataflowTable(): boolean { + return this.#ndb.hasDataflowTable(); + } + + getComplexityForNode(nodeId: number): ComplexityMetrics | undefined { + const r = this.#ndb.getComplexityForNode(nodeId); + return r ? toComplexityMetrics(r) : undefined; + } +} diff --git a/src/types.ts b/src/types.ts index b6d8f83e..3afe8c54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1895,7 +1895,112 @@ export interface NativeParseTreeCache { clear(): void; } -/** Native rusqlite database wrapper instance (Phase 6.13 + 6.15). */ +/** Row types returned by NativeDatabase query methods (auto-generated by napi-rs). */ +export interface NativeNodeRow { + id: number; + name: string; + kind: string; + file: string; + line: number | null; + endLine: number | null; + parentId: number | null; + exported: number | null; + qualifiedName: string | null; + scope: string | null; + visibility: string | null; + role: string | null; +} + +export interface NativeNodeRowWithFanIn extends NativeNodeRow { + fanIn: number; +} + +export interface NativeTriageNodeRow extends NativeNodeRow { + fanIn: number; + cognitive: number; + mi: number; + cyclomatic: number; + maxNesting: number; + churn: number; +} + +export interface NativeNodeIdRow { + id: number; + name: string; + kind: string; + line: number | null; +} + +export interface NativeChildNodeRow { + name: string; + kind: string; + line: number | null; + endLine: number | null; + qualifiedName: string | null; + scope: string | null; + visibility: string | null; +} + +export interface NativeRelatedNodeRow { + id: number; + name: string; + kind: string; + file: string; + line: number | null; + endLine: number | null; +} + +export interface NativeAdjacentEdgeRow { + name: string; + kind: string; + file: string; + line: number | null; + edgeKind: string; +} + +export interface NativeImportEdgeRow { + file: string; + edgeKind: string; +} + +export interface NativeIntraFileCallEdge { + callerName: string; + calleeName: string; +} + +export interface NativeCallableNodeRow { + id: number; + name: string; + kind: string; + file: string; +} + +export interface NativeCallEdgeRow { + sourceId: number; + targetId: number; + confidence: number | null; +} + +export interface NativeFileNodeRow { + id: number; + name: string; + file: string; +} + +export interface NativeImportGraphEdgeRow { + sourceId: number; + targetId: number; +} + +export interface NativeComplexityMetrics { + cognitive: number; + cyclomatic: number; + maxNesting: number; + maintainabilityIndex: number | null; + halsteadVolume: number | null; +} + +/** Native rusqlite database wrapper instance (Phase 6.13 + 6.14 + 6.15). */ export interface NativeDatabase { // ── Lifecycle (6.13) ──────────────────────────────────────────────── initSchema(): void; @@ -1907,6 +2012,75 @@ export interface NativeDatabase { readonly dbPath: string; readonly isOpen: boolean; + // ── Node lookups (6.14) ────────────────────────────────────────────── + countNodes(): number; + countEdges(): number; + countFiles(): number; + findNodeById(id: number): NativeNodeRow | null; + getNodeId(name: string, kind: string, file: string, line: number): number | null; + getFunctionNodeId(name: string, file: string, line: number): number | null; + bulkNodeIdsByFile(file: string): NativeNodeIdRow[]; + findNodeChildren(parentId: number): NativeChildNodeRow[]; + findNodesByFile(file: string): NativeNodeRow[]; + findFileNodes(fileLike: string): NativeNodeRow[]; + findNodesByScope( + scopeName: string, + kind: string | null | undefined, + file: string | null | undefined, + ): NativeNodeRow[]; + findNodeByQualifiedName(qualifiedName: string, file: string | null | undefined): NativeNodeRow[]; + findNodesWithFanIn( + namePattern: string, + kinds: string[] | null | undefined, + file: string | null | undefined, + ): NativeNodeRowWithFanIn[]; + findNodesForTriage( + kind: string | null | undefined, + role: string | null | undefined, + file: string | null | undefined, + noTests: boolean | null | undefined, + ): NativeTriageNodeRow[]; + listFunctionNodes( + file: string | null | undefined, + pattern: string | null | undefined, + noTests: boolean | null | undefined, + ): NativeNodeRow[]; + iterateFunctionNodes( + file: string | null | undefined, + pattern: string | null | undefined, + noTests: boolean | null | undefined, + ): NativeNodeRow[]; + + // ── Edge queries (6.14) ────────────────────────────────────────────── + findCallees(nodeId: number): NativeRelatedNodeRow[]; + findCallers(nodeId: number): NativeRelatedNodeRow[]; + findDistinctCallers(nodeId: number): NativeRelatedNodeRow[]; + findAllOutgoingEdges(nodeId: number): NativeAdjacentEdgeRow[]; + findAllIncomingEdges(nodeId: number): NativeAdjacentEdgeRow[]; + findCalleeNames(nodeId: number): string[]; + findCallerNames(nodeId: number): string[]; + findImportTargets(nodeId: number): NativeImportEdgeRow[]; + findImportSources(nodeId: number): NativeImportEdgeRow[]; + findImportDependents(nodeId: number): NativeNodeRow[]; + findCrossFileCallTargets(file: string): number[]; + countCrossFileCallers(nodeId: number, file: string): number; + getClassHierarchy(classNodeId: number): number[]; + findImplementors(nodeId: number): NativeRelatedNodeRow[]; + findInterfaces(nodeId: number): NativeRelatedNodeRow[]; + findIntraFileCallEdges(file: string): NativeIntraFileCallEdge[]; + + // ── Graph-read queries (6.14) ──────────────────────────────────────── + getCallableNodes(): NativeCallableNodeRow[]; + getCallEdges(): NativeCallEdgeRow[]; + getFileNodesAll(): NativeFileNodeRow[]; + getImportEdges(): NativeImportGraphEdgeRow[]; + + // ── Table checks (6.14) ────────────────────────────────────────────── + hasCfgTables(): boolean; + hasEmbeddings(): boolean; + hasDataflowTable(): boolean; + getComplexityForNode(nodeId: number): NativeComplexityMetrics | null; + // ── Build pipeline writes (6.15) ─────────────────────────────────── bulkInsertNodes( batches: Array<{