From 07b5e3e98775938af67eaa8ed3e6ab6e53ef5d2e Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Sun, 19 Jul 2026 13:37:44 -0400 Subject: [PATCH 1/3] feat: enrich INHERITS edges from SCIP's compiler-verified relationships import_scip_index defined an ImportStats.relations_added counter that was displayed in CLI output but never actually incremented -- SCIP's Relationship.is_implementation field (compiler/language-server-verified "Dog implements/extends Animal" data) was parsed into the index but discarded. Map is_implementation relationships onto RelationKind::Inherits, the same relation kind tree-sitter's @inherit.child/@inherit.parent captures already produce. SCIP has no separate flag for "extends" vs "implements" (confirmed against the scip crate's own doc comment, which uses exactly the Dog-implements-Animal example for is_implementation), and no language's relations.scm distinguishes them either, so this keeps both sources consistent. Resolution reuses the same scip_sym_to_file_name + file_name_to_ids two-step lookup and Parquet-COPY-with-UNWIND-fallback bulk write already used for CALLS edges. Verified end-to-end against a real scip-typescript index of lspeasy (the only available real indexer that emits is_implementation -- rust-analyzer's scip output has zero Relationship entries of any kind): INHERITS edges went from 38 to 249, with correct edges like WebSocketTransport.onError -> Transport.onError. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TAXDJyFdnA4BV1U2fxufdC --- crates/infigraph-core/src/scip/mod.rs | 232 ++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/crates/infigraph-core/src/scip/mod.rs b/crates/infigraph-core/src/scip/mod.rs index 793a9ea..be19e61 100644 --- a/crates/infigraph-core/src/scip/mod.rs +++ b/crates/infigraph-core/src/scip/mod.rs @@ -406,6 +406,81 @@ pub fn import_scip_index( let _ = std::fs::remove_file(&edge_pq); } + // Pass 3: build INHERITS edges from SCIP's compiler-verified is_implementation + // relationships (class/interface/trait implementation and inheritance). + // Mapped onto the same RelationKind::Inherits used by tree-sitter's + // @inherit.child/@inherit.parent captures, since no language's relations.scm + // currently distinguishes extends from implements. + let mut inherits_to_create: Vec<(String, String)> = Vec::new(); + let mut seen_inherits: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); + + for doc in &index.documents { + for si in &doc.symbols { + if si.symbol.starts_with("local ") || si.symbol.starts_with('<') { + continue; + } + let Some((sfile, sname)) = scip_sym_to_file_name.get(&si.symbol) else { + continue; + }; + let Some(source_id) = file_name_to_ids + .get(&(sfile.clone(), sname.clone())) + .and_then(|ids| ids.first()) + .cloned() + else { + continue; + }; + + for rel in &si.relationships { + if !rel.is_implementation { + continue; + } + let Some((tfile, tname)) = scip_sym_to_file_name.get(&rel.symbol) else { + continue; + }; + let Some(target_id) = file_name_to_ids + .get(&(tfile.clone(), tname.clone())) + .and_then(|ids| ids.first()) + .cloned() + else { + continue; + }; + + if source_id == target_id { + continue; + } + + let edge = (source_id.clone(), target_id); + if seen_inherits.insert(edge.clone()) { + inherits_to_create.push(edge); + } + } + } + } + + // Bulk write INHERITS edges via Parquet COPY FROM + if !inherits_to_create.is_empty() { + let tmp = std::env::temp_dir(); + let edge_pq = tmp.join("infigraph_scip_inherits.parquet"); + let refs: Vec<(&str, &str)> = inherits_to_create + .iter() + .map(|(a, b)| (a.as_str(), b.as_str())) + .collect(); + if parquet_loader::write_edge_parquet(&edge_pq, &refs).is_ok() { + if let Err(e) = conn.query(&format!( + "COPY INHERITS FROM '{}'", + fwd_slash_path(&edge_pq) + )) { + eprintln!("Auto-SCIP: COPY INHERITS failed ({e}), falling back to UNWIND"); + unwind_edges_from_pairs(&conn, &refs, "INHERITS", "Symbol", "Symbol"); + } + } else { + unwind_edges_from_pairs(&conn, &refs, "INHERITS", "Symbol", "Symbol"); + } + stats.relations_added = inherits_to_create.len(); + let _ = std::fs::remove_file(&edge_pq); + } + // Persist learned corrections (if any were recorded) if let Some(root) = project_root { if stats.corrections_learned > 0 { @@ -471,3 +546,160 @@ pub struct ImportStats { pub references_added: usize, pub corrections_learned: usize, } + +#[cfg(test)] +mod tests { + use super::*; + use scip::types::{Document, Occurrence, Relationship, SymbolInformation}; + + // Bare names (no scheme/package/descriptor-suffix structure) so these tests + // exercise only the is_implementation -> INHERITS mapping, independent of + // scip_sym_to_name's descriptor parsing. + fn scip_symbol(name: &str, _file: &str) -> String { + name.to_string() + } + + fn make_scip_index(file: &str, child: &str, parent: &str) -> Vec { + let child_sym = scip_symbol(child, file); + let parent_sym = scip_symbol(parent, file); + + let doc = Document { + relative_path: file.to_string(), + occurrences: vec![ + Occurrence { + range: vec![0, 0, 0, parent.len() as i32], + symbol: parent_sym.clone(), + symbol_roles: SymbolRole::Definition as i32, + ..Default::default() + }, + Occurrence { + range: vec![5, 0, 5, child.len() as i32], + symbol: child_sym.clone(), + symbol_roles: SymbolRole::Definition as i32, + ..Default::default() + }, + ], + symbols: vec![ + SymbolInformation { + symbol: parent_sym.clone(), + ..Default::default() + }, + SymbolInformation { + symbol: child_sym.clone(), + relationships: vec![Relationship { + symbol: parent_sym.clone(), + is_implementation: true, + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }; + + let index = Index { + documents: vec![doc], + ..Default::default() + }; + index.write_to_bytes().expect("serialize synthetic SCIP index") + } + + struct TestEnv { + _dir: tempfile::TempDir, + store: GraphStore, + } + + impl TestEnv { + fn new() -> Self { + let dir = tempfile::TempDir::new().unwrap(); + let store = GraphStore::open(&dir.path().join("graph")).unwrap(); + Self { _dir: dir, store } + } + } + + #[test] + fn is_implementation_relationship_creates_inherits_edge() { + let env = TestEnv::new(); + let bytes = make_scip_index("test.ts", "Dog", "Animal"); + + let index_path = env._dir.path().join("index.scip"); + std::fs::write(&index_path, bytes).unwrap(); + + let stats = import_scip_index(&index_path, &env.store, None).unwrap(); + assert_eq!(stats.relations_added, 1); + + let conn = env.store.connection().unwrap(); + let rows = conn + .query("MATCH (a:Symbol)-[:INHERITS]->(b:Symbol) RETURN a.name, b.name") + .unwrap(); + let pairs: Vec<(String, String)> = rows + .into_iter() + .map(|row| { + ( + row[0].to_string().trim_matches('"').to_string(), + row[1].to_string().trim_matches('"').to_string(), + ) + }) + .collect(); + assert_eq!(pairs, vec![("Dog".to_string(), "Animal".to_string())]); + } + + #[test] + fn non_implementation_relationship_does_not_create_inherits_edge() { + let env = TestEnv::new(); + let file = "test.ts"; + let child_sym = scip_symbol("Dog", file); + let parent_sym = scip_symbol("Animal", file); + + let doc = Document { + relative_path: file.to_string(), + occurrences: vec![ + Occurrence { + range: vec![0, 0, 0, 6], + symbol: parent_sym.clone(), + symbol_roles: SymbolRole::Definition as i32, + ..Default::default() + }, + Occurrence { + range: vec![5, 0, 5, 3], + symbol: child_sym.clone(), + symbol_roles: SymbolRole::Definition as i32, + ..Default::default() + }, + ], + symbols: vec![ + SymbolInformation { + symbol: parent_sym.clone(), + ..Default::default() + }, + SymbolInformation { + symbol: child_sym.clone(), + // is_reference only, NOT is_implementation -- must not become INHERITS. + relationships: vec![Relationship { + symbol: parent_sym.clone(), + is_reference: true, + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }; + let index = Index { + documents: vec![doc], + ..Default::default() + }; + let bytes = index.write_to_bytes().unwrap(); + let index_path = env._dir.path().join("index.scip"); + std::fs::write(&index_path, bytes).unwrap(); + + let stats = import_scip_index(&index_path, &env.store, None).unwrap(); + assert_eq!(stats.relations_added, 0); + + let conn = env.store.connection().unwrap(); + let rows = conn + .query("MATCH (a:Symbol)-[:INHERITS]->(b:Symbol) RETURN a.name, b.name") + .unwrap(); + assert!(rows.into_iter().next().is_none()); + } +} From 784abe22ed4be2f57a52fe7342c51969c391c720 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 12:28:02 -0400 Subject: [PATCH 2/3] chore: cargo fmt + allow pre-existing clippy lints blocking commits Unrelated to this PR's SCIP INHERITS-enrichment change, but the pre-commit hook's fmt/clippy checks block all commits until fixed. - scip/mod.rs: cargo fmt drift - lsp-to-scip/main.rs, vuln/mod.rs: allow question_mark / useless_borrows_in_formatting lints that newly apply because the 'stable' toolchain drifted since this code was written in May, not because the code changed (CI's dtolnay/rust-toolchain@stable also resolves to rustc 1.97.x). --- crates/infigraph-core/src/scip/mod.rs | 4 +++- crates/infigraph-core/src/vuln/mod.rs | 1 + crates/lsp-to-scip/src/main.rs | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/infigraph-core/src/scip/mod.rs b/crates/infigraph-core/src/scip/mod.rs index be19e61..74471b0 100644 --- a/crates/infigraph-core/src/scip/mod.rs +++ b/crates/infigraph-core/src/scip/mod.rs @@ -601,7 +601,9 @@ mod tests { documents: vec![doc], ..Default::default() }; - index.write_to_bytes().expect("serialize synthetic SCIP index") + index + .write_to_bytes() + .expect("serialize synthetic SCIP index") } struct TestEnv { diff --git a/crates/infigraph-core/src/vuln/mod.rs b/crates/infigraph-core/src/vuln/mod.rs index 8133dc9..a34e0ce 100644 --- a/crates/infigraph-core/src/vuln/mod.rs +++ b/crates/infigraph-core/src/vuln/mod.rs @@ -416,6 +416,7 @@ pub fn format_table(report: &VulnReport) -> String { } else { f.summary.clone() }; + #[allow(clippy::useless_borrows_in_formatting)] out.push_str(&format!( " {:<20} {:<12} {:<18} {:<10} {}\n", truncate_str(&f.dep_name, 20), diff --git a/crates/lsp-to-scip/src/main.rs b/crates/lsp-to-scip/src/main.rs index 066e543..ffc82e6 100644 --- a/crates/lsp-to-scip/src/main.rs +++ b/crates/lsp-to-scip/src/main.rs @@ -495,6 +495,7 @@ fn parse_symbol(v: &serde_json::Value) -> Option { // DocumentSymbol has range + selectionRange // SymbolInformation has location.range + #[allow(clippy::question_mark)] let (range, sel_range) = if let Some(r) = v.get("range") { let range = parse_range(r)?; let sel = v From ba2f9e4cbff67e0cca67abaca7c8faaf89c69d13 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 14:06:05 -0400 Subject: [PATCH 3/3] fix: replace clippy lint suppressions with proper fixes Rewrites the two spots suppressed in the prior commit instead of disabling the lints: - lsp-to-scip/main.rs: replace if-let/else-if-let/else with ? operator - vuln/mod.rs: remove redundant & in format! arg --- crates/infigraph-core/src/vuln/mod.rs | 3 +-- crates/lsp-to-scip/src/main.rs | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/infigraph-core/src/vuln/mod.rs b/crates/infigraph-core/src/vuln/mod.rs index a34e0ce..79c3b86 100644 --- a/crates/infigraph-core/src/vuln/mod.rs +++ b/crates/infigraph-core/src/vuln/mod.rs @@ -416,13 +416,12 @@ pub fn format_table(report: &VulnReport) -> String { } else { f.summary.clone() }; - #[allow(clippy::useless_borrows_in_formatting)] out.push_str(&format!( " {:<20} {:<12} {:<18} {:<10} {}\n", truncate_str(&f.dep_name, 20), truncate_str(&f.dep_version, 12), truncate_str(&f.vuln_id, 18), - &f.severity, + f.severity, summary_truncated, )); } diff --git a/crates/lsp-to-scip/src/main.rs b/crates/lsp-to-scip/src/main.rs index ffc82e6..3f15dae 100644 --- a/crates/lsp-to-scip/src/main.rs +++ b/crates/lsp-to-scip/src/main.rs @@ -495,7 +495,6 @@ fn parse_symbol(v: &serde_json::Value) -> Option { // DocumentSymbol has range + selectionRange // SymbolInformation has location.range - #[allow(clippy::question_mark)] let (range, sel_range) = if let Some(r) = v.get("range") { let range = parse_range(r)?; let sel = v @@ -503,11 +502,10 @@ fn parse_symbol(v: &serde_json::Value) -> Option { .and_then(parse_range) .unwrap_or(range.clone()); (range, sel) - } else if let Some(loc) = v.get("location") { + } else { + let loc = v.get("location")?; let range = parse_range(&loc["range"])?; (range.clone(), range) - } else { - return None; }; Some(LspSymbol {