1use std::collections::{BTreeMap, BTreeSet};
2
3use rusqlite::{Connection, OptionalExtension, params};
4use serde::Serialize;
5
6use crate::query::graph::{self, GraphHop, GraphResolutionMode, GraphTraversalOptions};
7use crate::query::memory::{self, RepoMemoryEvidence};
8use crate::query::symbol::SymbolHit;
9
10#[derive(Debug, Serialize)]
11pub struct ImpactItem {
12 pub path: String,
13 pub language: String,
14 pub kind: String,
15 pub symbol: Option<String>,
16 pub category: String,
17 pub reason: String,
18 pub evidence: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ImpactSurfaceOptions {
23 pub resolution_mode: GraphResolutionMode,
24 pub include_tests: bool,
25 pub include_docs: bool,
26 pub include_git: bool,
27 pub include_papertrail: bool,
28 pub include_text_fallback: bool,
29 pub include_memories: bool,
30}
31
32#[derive(Debug, Serialize)]
33pub struct ImpactSurfaceReport {
34 pub query: ImpactSurfaceQuery,
35 pub direct_semantic_callers: Vec<GraphHop>,
36 pub direct_semantic_callees: Vec<GraphHop>,
37 pub import_export_dependents: Vec<ImpactItem>,
38 pub tests_touching_symbol_path: Vec<ImpactItem>,
39 pub docs_mentioning_symbol_path: Vec<ImpactItem>,
40 pub text_fallback_hits: Vec<ImpactItem>,
41 pub recent_commits_touching_symbol_path: Vec<ImpactItem>,
42 pub github_rationale_issues_prs: Vec<ImpactItem>,
43 pub repo_memories: RepoMemoryEvidence,
44 pub completeness_and_caveats: ImpactCompleteness,
45}
46
47#[derive(Debug, Serialize)]
48pub struct ImpactSurfaceQuery {
49 pub symbol_id: Option<i64>,
50 pub symbol_path: Option<String>,
51 pub query: Option<String>,
52 pub resolution: String,
53 pub include_tests: bool,
54 pub include_docs: bool,
55 pub include_git: bool,
56 pub include_papertrail: bool,
57 pub include_text_fallback: bool,
58 pub include_memories: bool,
59}
60
61#[derive(Debug, Default, Serialize)]
62pub struct ImpactCompleteness {
63 pub exact_graph_callers: u64,
64 pub graph_callees: u64,
65 pub text_fallback_hits: u64,
66 pub parser_failures: u64,
67 pub stale_files: u64,
68 pub memory_status: ImpactMemoryStatus,
69 pub caveats: Vec<String>,
70}
71
72#[derive(Debug, Default, Serialize)]
73pub struct ImpactMemoryStatus {
74 pub active: u64,
75 pub stale: u64,
76}
77
78impl Default for ImpactSurfaceOptions {
79 fn default() -> Self {
80 Self {
81 resolution_mode: GraphResolutionMode::Syntactic,
82 include_tests: true,
83 include_docs: true,
84 include_git: true,
85 include_papertrail: true,
86 include_text_fallback: true,
87 include_memories: true,
88 }
89 }
90}
91
92pub fn impact_surface(
93 conn: &Connection,
94 query: &str,
95 limit: u32,
96) -> anyhow::Result<Vec<ImpactItem>> {
97 impact_surface_with_options(conn, query, limit, GraphResolutionMode::Syntactic)
98}
99
100pub fn impact_surface_report_for_symbol(
101 conn: &Connection,
102 symbol: &SymbolHit,
103 limit: u32,
104 options: &ImpactSurfaceOptions,
105) -> anyhow::Result<ImpactSurfaceReport> {
106 let graph_options = GraphTraversalOptions {
107 resolution_mode: options.resolution_mode,
108 symbol_id: Some(symbol.symbol_id),
109 logical_symbol_id: symbol.logical_symbol_id,
110 ..Default::default()
111 };
112 let direct_semantic_callers =
113 graph::traverse_with_options(conn, &symbol.qualified_name, true, limit, &graph_options)?;
114 let direct_semantic_callees =
115 graph::traverse_with_options(conn, &symbol.qualified_name, false, limit, &graph_options)?;
116 let names = vec![symbol.name.clone(), symbol.qualified_name.clone()];
117 let import_export_dependents =
118 import_export_items(conn, symbol.symbol_id, &symbol.qualified_name, &names, limit)?;
119 let tests_touching_symbol_path =
120 if options.include_tests { test_items(conn, symbol, &names, limit)? } else { Vec::new() };
121 let docs_mentioning_symbol_path =
122 if options.include_docs { docs_items(conn, symbol, &names, limit)? } else { Vec::new() };
123 let text_fallback_hits = if options.include_text_fallback {
124 text_fallback_items(conn, symbol, &names, limit)?
125 } else {
126 Vec::new()
127 };
128 let recent_commits_touching_symbol_path = if options.include_git {
129 git_commit_items(conn, std::slice::from_ref(&symbol.path), limit)?
130 } else {
131 Vec::new()
132 };
133 let github_rationale_issues_prs = if options.include_papertrail {
134 let mut items = github_ref_items(conn, std::slice::from_ref(&symbol.path), limit)?;
135 items.extend(github_rationale_items(conn, &symbol.qualified_name, limit)?);
136 items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
137 items
138 } else {
139 Vec::new()
140 };
141 let repo_memories = if options.include_memories {
142 let crossed_edge_ids = direct_semantic_callers
143 .iter()
144 .chain(direct_semantic_callees.iter())
145 .map(|hop| hop.edge_id)
146 .collect::<Vec<_>>();
147 memory::memory_evidence_for_symbol_and_edges(conn, symbol, &crossed_edge_ids, limit)?
148 } else {
149 RepoMemoryEvidence { direct: Vec::new(), path_crossed: Vec::new(), stale: Vec::new() }
150 };
151 let mut caveats = vec![
152 "Graph evidence is tree-sitter/syntactic, not compiler-grade name resolution.".to_string(),
153 ];
154 if options.resolution_mode == GraphResolutionMode::Exact
155 && direct_semantic_callers.is_empty()
156 && !text_fallback_hits.is_empty()
157 {
158 caveats.push(format!(
159 "No exact graph callers found. Text search found {} symbol/path hits. This likely indicates graph extraction or resolution gaps.",
160 text_fallback_hits.len()
161 ));
162 }
163 Ok(ImpactSurfaceReport {
164 query: ImpactSurfaceQuery {
165 symbol_id: Some(symbol.symbol_id),
166 symbol_path: Some(symbol.qualified_name.clone()),
167 query: None,
168 resolution: options.resolution_mode.as_str().to_string(),
169 include_tests: options.include_tests,
170 include_docs: options.include_docs,
171 include_git: options.include_git,
172 include_papertrail: options.include_papertrail,
173 include_text_fallback: options.include_text_fallback,
174 include_memories: options.include_memories,
175 },
176 completeness_and_caveats: ImpactCompleteness {
177 exact_graph_callers: u64::try_from(direct_semantic_callers.len()).unwrap_or(u64::MAX),
178 graph_callees: u64::try_from(direct_semantic_callees.len()).unwrap_or(u64::MAX),
179 text_fallback_hits: u64::try_from(text_fallback_hits.len()).unwrap_or(u64::MAX),
180 parser_failures: parser_failure_count(conn)?,
181 stale_files: 0,
182 memory_status: ImpactMemoryStatus {
183 active: u64::try_from(
184 repo_memories.direct.len() + repo_memories.path_crossed.len(),
185 )
186 .unwrap_or(u64::MAX),
187 stale: u64::try_from(repo_memories.stale.len()).unwrap_or(u64::MAX),
188 },
189 caveats,
190 },
191 direct_semantic_callers,
192 direct_semantic_callees,
193 import_export_dependents,
194 tests_touching_symbol_path,
195 docs_mentioning_symbol_path,
196 text_fallback_hits,
197 recent_commits_touching_symbol_path,
198 github_rationale_issues_prs,
199 repo_memories,
200 })
201}
202
203pub fn impact_surface_with_options(
204 conn: &Connection,
205 query: &str,
206 limit: u32,
207 resolution_mode: GraphResolutionMode,
208) -> anyhow::Result<Vec<ImpactItem>> {
209 impact_surface_from_targets(conn, query, None, limit, resolution_mode)
210}
211
212pub fn impact_surface_for_symbol(
213 conn: &Connection,
214 symbol: &SymbolHit,
215 limit: u32,
216 resolution_mode: GraphResolutionMode,
217) -> anyhow::Result<Vec<ImpactItem>> {
218 let target = SymbolTarget {
219 id: symbol.symbol_id,
220 file_id: symbol.file_id,
221 path: symbol.path.clone(),
222 language: symbol.language.clone(),
223 file_kind: symbol.file_kind.clone(),
224 name: symbol.name.clone(),
225 qualified_name: symbol.qualified_name.clone(),
226 };
227 impact_surface_from_targets(
228 conn,
229 &symbol.qualified_name,
230 Some(vec![target]),
231 limit,
232 resolution_mode,
233 )
234}
235
236fn impact_surface_from_targets(
237 conn: &Connection,
238 query: &str,
239 selected_targets: Option<Vec<SymbolTarget>>,
240 limit: u32,
241 resolution_mode: GraphResolutionMode,
242) -> anyhow::Result<Vec<ImpactItem>> {
243 let max_items = usize::try_from(limit).unwrap_or(usize::MAX);
244 let mut surface = ImpactSurface::default();
245 let targets = match selected_targets {
246 Some(targets) => targets,
247 None => exact_symbols(conn, query)?,
248 };
249 let target_names = target_names(query, &targets);
250
251 for symbol in &targets {
252 surface.push(
253 ImpactCategory::DirectStructural,
254 FileSymbol {
255 path: symbol.path.clone(),
256 language: symbol.language.clone(),
257 kind: symbol.file_kind.clone(),
258 symbol: Some(symbol.qualified_name.clone()),
259 },
260 "exact_symbol_definition",
261 format!("defined as {}", symbol.qualified_name),
262 );
263 }
264
265 graph_neighbors(conn, &targets, &target_names, true, resolution_mode, &mut surface)?;
266 graph_neighbors(conn, &targets, &target_names, false, resolution_mode, &mut surface)?;
267 import_export_dependents(conn, &targets, &target_names, &mut surface)?;
268 same_file_siblings(conn, &targets, &mut surface)?;
269
270 if surface.len() < max_items {
271 let remaining = max_items.saturating_sub(surface.len());
272 textual_fallback(conn, query, &mut surface, remaining)?;
273 }
274
275 let current_paths = surface.current_paths();
276 historical_evidence(conn, ¤t_paths, query, &mut surface, max_items)?;
277
278 Ok(surface.into_items(max_items))
279}
280
281pub fn ffi_surface(conn: &Connection, limit: u32) -> anyhow::Result<Vec<ImpactItem>> {
282 let mut stmt = conn.prepare(
283 "
284 WITH rust_exports AS (
285 SELECT DISTINCT
286 files.path AS path,
287 files.language AS language,
288 files.kind AS kind,
289 symbols.qualified_name AS symbol,
290 CASE
291 WHEN symbols.kind = 'impl' THEN 'rust_uniffi_exported_impl'
292 ELSE 'rust_uniffi_export'
293 END AS reason
294 FROM symbols
295 JOIN files ON files.id = symbols.file_id
296 JOIN symbol_facts
297 ON symbol_facts.symbol_id = symbols.id
298 AND symbol_facts.fact_kind = 'rust_attr'
299 AND symbol_facts.fact_value = 'uniffi_export'
300 WHERE files.language = 'rust'
301 AND symbols.kind IN ('function', 'method', 'impl', 'struct', 'enum', 'trait')
302 ),
303 rust_exported_impl_members AS (
304 SELECT DISTINCT
305 files.path AS path,
306 files.language AS language,
307 files.kind AS kind,
308 members.qualified_name AS symbol,
309 'rust_uniffi_impl_member' AS reason
310 FROM symbols AS impls
311 JOIN files ON files.id = impls.file_id
312 JOIN symbol_facts
313 ON symbol_facts.symbol_id = impls.id
314 AND symbol_facts.fact_kind = 'rust_attr'
315 AND symbol_facts.fact_value = 'uniffi_export'
316 JOIN symbols AS members
317 ON members.file_id = impls.file_id
318 AND members.start_byte > impls.start_byte
319 AND members.end_byte < impls.end_byte
320 AND members.kind IN ('function', 'method')
321 WHERE files.language = 'rust'
322 AND impls.kind = 'impl'
323 ),
324 binding_refs AS (
325 -- Generated/binding artifacts detected by path. Detection is generic on purpose:
326 -- matching specific native-symbol substrings in chunk text was project-specific and
327 -- self-matched any source that merely mentions those names (e.g. this query). The
328 -- `#[uniffi::export]` symbol facts above are the principled, language-level signal.
329 SELECT DISTINCT
330 files.path AS path,
331 files.language AS language,
332 files.kind AS kind,
333 chunks.symbol_path AS symbol,
334 'generated_binding_artifact' AS reason
335 FROM files
336 JOIN chunks ON chunks.file_id = files.id
337 WHERE files.path LIKE '%/src/generated/%'
338 OR files.path LIKE '%/generated/%'
339 OR files.path LIKE '%generated-manifest.json'
340 )
341 SELECT path, language, kind, symbol, reason FROM rust_exports
342 UNION
343 SELECT path, language, kind, symbol, reason FROM rust_exported_impl_members
344 UNION
345 SELECT path, language, kind, symbol, reason FROM binding_refs
346 ORDER BY reason, kind DESC, path
347 LIMIT ?1
348 ",
349 )?;
350 rows_to_items(stmt.query_map([limit], |row| {
351 let reason: String = row.get(4)?;
352 Ok(ImpactItem {
353 path: row.get(0)?,
354 language: row.get(1)?,
355 kind: row.get(2)?,
356 symbol: row.get(3)?,
357 category: ImpactCategory::ProbableTextual.as_str().to_string(),
358 reason: reason.clone(),
359 evidence: ffi_surface_evidence(&reason),
360 })
361 })?)
362}
363
364fn ffi_surface_evidence(reason: &str) -> Vec<String> {
365 let mut evidence = vec![format!("ffi_surface evidence class: {reason}")];
366 match reason {
367 "rust_uniffi_impl_member" => {
368 evidence.push(
369 "member symbol is inside a chunk containing an exported UniFFI impl".to_string(),
370 );
371 evidence.push(
372 "this row is not claiming the member itself has a #[uniffi::export] attribute"
373 .to_string(),
374 );
375 },
376 "rust_uniffi_exported_impl" => {
377 evidence.push(
378 "exported impl/type surface; member rows are reported separately when symbols are available"
379 .to_string(),
380 );
381 },
382 _ => {},
383 }
384 evidence
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
388enum ImpactCategory {
389 DirectStructural,
390 ProbableTextual,
391 HistoricalPapertrail,
392}
393
394impl ImpactCategory {
395 fn as_str(self) -> &'static str {
396 match self {
397 Self::DirectStructural => "Direct structural impact",
398 Self::ProbableTextual => "Probable textual impact",
399 Self::HistoricalPapertrail => "Historical/papertrail evidence",
400 }
401 }
402}
403
404#[derive(Debug, Clone)]
405struct FileSymbol {
406 path: String,
407 language: String,
408 kind: String,
409 symbol: Option<String>,
410}
411
412#[derive(Debug, Clone)]
413struct SymbolTarget {
414 id: i64,
415 file_id: i64,
416 path: String,
417 language: String,
418 file_kind: String,
419 name: String,
420 qualified_name: String,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
424struct ImpactKey {
425 category: &'static str,
426 path: String,
427 symbol: Option<String>,
428 reason: String,
429}
430
431#[derive(Default)]
432struct ImpactSurface {
433 items: BTreeMap<ImpactKey, ImpactItem>,
434}
435
436impl ImpactSurface {
437 fn len(&self) -> usize {
438 self.items.len()
439 }
440
441 fn push(
442 &mut self,
443 category: ImpactCategory,
444 file_symbol: FileSymbol,
445 reason: impl Into<String>,
446 evidence: impl Into<String>,
447 ) {
448 let reason = reason.into();
449 let key = ImpactKey {
450 category: category.as_str(),
451 path: file_symbol.path.clone(),
452 symbol: file_symbol.symbol.clone(),
453 reason: reason.clone(),
454 };
455 let item = self.items.entry(key).or_insert_with(|| ImpactItem {
456 path: file_symbol.path,
457 language: file_symbol.language,
458 kind: file_symbol.kind,
459 symbol: file_symbol.symbol,
460 category: category.as_str().to_string(),
461 reason,
462 evidence: Vec::new(),
463 });
464 let evidence = evidence.into();
465 if !item.evidence.iter().any(|value| value == &evidence) {
466 item.evidence.push(evidence);
467 }
468 }
469
470 fn current_paths(&self) -> Vec<String> {
471 let mut paths = BTreeSet::new();
472 for item in self.items.values() {
473 if item.category != ImpactCategory::HistoricalPapertrail.as_str() {
474 paths.insert(item.path.clone());
475 }
476 }
477 paths.into_iter().collect()
478 }
479
480 fn into_items(self, limit: usize) -> Vec<ImpactItem> {
481 let mut items = self.items.into_values().collect::<Vec<_>>();
482 items.sort_by_key(|item| {
483 (
484 category_rank(&item.category),
485 reason_rank(&item.reason),
486 item.path.clone(),
487 item.symbol.clone().unwrap_or_default(),
488 )
489 });
490 items.truncate(limit);
491 items
492 }
493}
494
495fn category_rank(category: &str) -> u8 {
496 match category {
497 "Direct structural impact" => 0,
498 "Probable textual impact" => 1,
499 "Historical/papertrail evidence" => 2,
500 _ => 3,
501 }
502}
503
504fn reason_rank(reason: &str) -> u8 {
505 match reason {
506 "exact_symbol_definition" => 0,
507 "direct_caller" => 1,
508 "direct_callee" => 2,
509 "import_export_dependent" => 3,
510 "same_file_sibling" => 4,
511 "textual_fallback" => 5,
512 "git_commit_touched_file" => 6,
513 "github_papertrail" => 7,
514 _ => 8,
515 }
516}
517
518fn exact_symbols(conn: &Connection, query: &str) -> anyhow::Result<Vec<SymbolTarget>> {
519 let candidates = symbol_query_candidates(query);
520 if candidates.is_empty() {
521 return Ok(Vec::new());
522 }
523 let mut stmt = conn.prepare(
524 "
525 SELECT symbols.id, symbols.file_id, files.path, files.language, files.kind,
526 symbols.name, symbols.qualified_name
527 FROM symbols
528 JOIN files ON files.id = symbols.file_id
529 WHERE symbols.name = ?1 OR symbols.qualified_name = ?1
530 ORDER BY files.kind, files.path, symbols.start_byte
531 ",
532 )?;
533 let mut targets = Vec::new();
534 let mut seen = BTreeSet::new();
535 let multi_candidate_query = candidates.len() > 1;
536 for candidate in candidates {
537 let qualified_candidate = is_qualified_symbol(candidate);
538 if multi_candidate_query && !qualified_candidate && !is_high_signal_query_token(candidate) {
539 continue;
540 }
541 let rows = stmt.query_map([candidate], |row| {
542 Ok(SymbolTarget {
543 id: row.get(0)?,
544 file_id: row.get(1)?,
545 path: row.get(2)?,
546 language: row.get(3)?,
547 file_kind: row.get(4)?,
548 name: row.get(5)?,
549 qualified_name: row.get(6)?,
550 })
551 })?;
552 let rows = collect_rows(rows)?;
553 if !qualified_candidate && !is_high_signal_symbol_candidate(&rows) {
554 continue;
555 }
556 for row in rows {
557 if seen.insert(row.id) {
558 targets.push(row);
559 }
560 }
561 }
562 Ok(targets)
563}
564
565fn is_high_signal_query_token(value: &str) -> bool {
566 value.contains('_') || value.chars().next().is_some_and(char::is_uppercase)
567}
568
569fn is_high_signal_symbol_candidate(rows: &[SymbolTarget]) -> bool {
570 match rows {
571 [] => false,
572 [_] => true,
573 [first, ..] if rows.len() <= 4 => {
574 rows.iter().all(|row| row.path == first.path && row.name == first.name)
575 },
576 _ => false,
577 }
578}
579
580fn target_names(query: &str, targets: &[SymbolTarget]) -> Vec<String> {
581 let mut names = BTreeSet::new();
582 for candidate in symbol_query_candidates(query) {
583 names.insert(candidate.to_string());
584 names.insert(short_symbol_name(candidate).to_string());
585 }
586 for target in targets {
587 names.insert(target.name.clone());
588 names.insert(target.qualified_name.clone());
589 }
590 names.into_iter().collect()
591}
592
593fn symbol_query_candidates(query: &str) -> Vec<&str> {
594 query
595 .split_whitespace()
596 .map(|token| {
597 token.trim_matches(|ch: char| {
598 !(ch.is_alphanumeric() || matches!(ch, '_' | ':' | '/' | '.' | '-'))
599 })
600 })
601 .filter(|token| !token.is_empty())
602 .filter(|token| token.contains("::") || is_non_stopword_identifier(token))
603 .collect()
604}
605
606fn is_non_stopword_identifier(value: &str) -> bool {
607 let mut chars = value.chars();
608 let Some(first) = chars.next() else {
609 return false;
610 };
611 let is_identifier = (first == '_' || first.is_ascii_alphabetic())
612 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric());
613 is_identifier
614 && !matches!(
615 value,
616 "of" | "in"
617 | "to"
618 | "from"
619 | "for"
620 | "and"
621 | "or"
622 | "the"
623 | "callers"
624 | "callee"
625 | "callees"
626 | "caller"
627 | "impact"
628 | "symbol"
629 )
630}
631
632fn short_symbol_name(value: &str) -> &str {
633 value.rsplit([':', '.', '#', '/']).find(|part| !part.is_empty()).unwrap_or(value)
634}
635
636fn is_qualified_symbol(value: &str) -> bool {
637 value.contains("::") || value.contains('/')
638}
639
640fn graph_neighbors(
641 conn: &Connection,
642 targets: &[SymbolTarget],
643 target_names: &[String],
644 reverse: bool,
645 resolution_mode: GraphResolutionMode,
646 surface: &mut ImpactSurface,
647) -> anyhow::Result<()> {
648 let reason = if reverse { "direct_caller" } else { "direct_callee" };
649 let source_path_col = if reverse {
650 "COALESCE(source_files.path, from_files.path)"
651 } else {
652 "COALESCE(to_files.path, source_files.path)"
653 };
654 let source_language_col = if reverse {
655 "COALESCE(source_files.language, from_files.language)"
656 } else {
657 "COALESCE(to_files.language, source_files.language)"
658 };
659 let source_kind_col = if reverse {
660 "COALESCE(source_files.kind, from_files.kind)"
661 } else {
662 "COALESCE(to_files.kind, source_files.kind)"
663 };
664 let source_symbol_col = if reverse {
665 "COALESCE(from_symbols.qualified_name, edges.from_name)"
666 } else {
667 "COALESCE(to_symbols.qualified_name, edges.to_name)"
668 };
669 let predicate = impact_graph_predicate(reverse, resolution_mode);
670 let sql = format!(
671 "
672 SELECT {source_path_col}, {source_language_col}, {source_kind_col},
673 {source_symbol_col}, edges.edge_kind, edges.confidence
674 FROM edges
675 LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
676 LEFT JOIN files from_files ON from_files.id = from_symbols.file_id
677 LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
678 LEFT JOIN files to_files ON to_files.id = to_symbols.file_id
679 LEFT JOIN files source_files ON source_files.id = edges.source_file_id
680 WHERE edges.edge_kind IN ('calls_name', 'constructs', 'implements')
681 AND ({predicate})
682 AND {source_path_col} IS NOT NULL
683 ORDER BY
684 CASE edges.confidence
685 WHEN 'Exact' THEN 0
686 WHEN 'Syntactic' THEN 1
687 WHEN 'NameOnly' THEN 2
688 ELSE 3
689 END,
690 edges.edge_kind,
691 {source_path_col},
692 {source_symbol_col}
693 ",
694 );
695 let mut stmt = conn.prepare(&sql)?;
696 for target in targets {
697 let rows = stmt.query_map(params![target.id, target.qualified_name], |row| {
698 Ok((
699 row.get::<_, String>(0)?,
700 row.get::<_, String>(1)?,
701 row.get::<_, String>(2)?,
702 row.get::<_, Option<String>>(3)?,
703 row.get::<_, String>(4)?,
704 row.get::<_, String>(5)?,
705 ))
706 })?;
707 for row in rows {
708 let (path, language, kind, symbol, edge_kind, confidence) = row?;
709 surface.push(
710 ImpactCategory::DirectStructural,
711 FileSymbol { path, language, kind, symbol },
712 reason,
713 format!("{edge_kind} edge to {} ({confidence})", target.qualified_name),
714 );
715 }
716 }
717 for name in target_names {
718 if resolution_mode != GraphResolutionMode::Fuzzy && !is_qualified_symbol(name) {
719 continue;
720 }
721 let rows = stmt.query_map(params![Option::<i64>::None, name], |row| {
722 Ok((
723 row.get::<_, String>(0)?,
724 row.get::<_, String>(1)?,
725 row.get::<_, String>(2)?,
726 row.get::<_, Option<String>>(3)?,
727 row.get::<_, String>(4)?,
728 row.get::<_, String>(5)?,
729 ))
730 })?;
731 for row in rows {
732 let (path, language, kind, symbol, edge_kind, confidence) = row?;
733 surface.push(
734 ImpactCategory::DirectStructural,
735 FileSymbol { path, language, kind, symbol },
736 reason,
737 format!("{edge_kind} edge matching {name} ({confidence})"),
738 );
739 }
740 }
741 Ok(())
742}
743
744fn impact_graph_predicate(reverse: bool, mode: GraphResolutionMode) -> &'static str {
745 match (reverse, mode) {
746 (true, GraphResolutionMode::Exact) => "edges.to_symbol_id = ?1",
747 (false, GraphResolutionMode::Exact) => {
748 "edges.from_symbol_id = ?1 AND edges.to_symbol_id IS NOT NULL"
749 },
750 (true, GraphResolutionMode::Syntactic) => {
751 "edges.to_symbol_id = ?1 OR edges.target_qualified_name = ?2"
752 },
753 (false, GraphResolutionMode::Syntactic) => {
754 "(edges.from_symbol_id = ?1 OR edges.from_name = ?2)
755 AND (edges.to_symbol_id IS NOT NULL OR edges.target_qualified_name IS NOT NULL)"
756 },
757 (true, GraphResolutionMode::Fuzzy) => "edges.to_symbol_id = ?1 OR edges.to_name = ?2",
758 (false, GraphResolutionMode::Fuzzy) => "edges.from_symbol_id = ?1 OR edges.from_name = ?2",
759 }
760}
761
762fn import_export_dependents(
763 conn: &Connection,
764 targets: &[SymbolTarget],
765 target_names: &[String],
766 surface: &mut ImpactSurface,
767) -> anyhow::Result<()> {
768 let mut stmt = conn.prepare(
769 "
770 SELECT files.path, files.language, files.kind, edges.from_name,
771 edges.edge_kind, edges.confidence
772 FROM edges
773 JOIN files ON files.id = edges.source_file_id
774 WHERE edges.edge_kind IN ('imports', 'exports')
775 AND (edges.to_symbol_id = ?1 OR edges.to_name = ?2)
776 ORDER BY files.kind, files.path, edges.edge_kind
777 ",
778 )?;
779 for target in targets {
780 let rows = stmt.query_map(params![target.id, target.qualified_name], import_export_row)?;
781 push_import_export_rows(rows, target.qualified_name.as_str(), surface)?;
782 }
783 for name in target_names {
784 let rows = stmt.query_map(params![Option::<i64>::None, name], import_export_row)?;
785 push_import_export_rows(rows, name, surface)?;
786 }
787 Ok(())
788}
789
790fn import_export_row(
791 row: &rusqlite::Row<'_>,
792) -> rusqlite::Result<(String, String, String, Option<String>, String, String)> {
793 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?))
794}
795
796fn push_import_export_rows(
797 rows: rusqlite::MappedRows<
798 '_,
799 impl FnMut(
800 &rusqlite::Row<'_>,
801 )
802 -> rusqlite::Result<(String, String, String, Option<String>, String, String)>,
803 >,
804 target: &str,
805 surface: &mut ImpactSurface,
806) -> anyhow::Result<()> {
807 for row in rows {
808 let (path, language, kind, symbol, edge_kind, confidence) = row?;
809 surface.push(
810 ImpactCategory::DirectStructural,
811 FileSymbol { path, language, kind, symbol },
812 "import_export_dependent",
813 format!("{edge_kind} edge matching {target} ({confidence})"),
814 );
815 }
816 Ok(())
817}
818
819fn same_file_siblings(
820 conn: &Connection,
821 targets: &[SymbolTarget],
822 surface: &mut ImpactSurface,
823) -> anyhow::Result<()> {
824 let mut stmt = conn.prepare(
825 "
826 SELECT files.path, files.language, files.kind, symbols.qualified_name
827 FROM symbols
828 JOIN files ON files.id = symbols.file_id
829 WHERE symbols.file_id = ?1 AND symbols.id != ?2
830 ORDER BY symbols.start_byte
831 LIMIT 20
832 ",
833 )?;
834 for target in targets {
835 let rows = stmt.query_map(params![target.file_id, target.id], |row| {
836 Ok(FileSymbol {
837 path: row.get(0)?,
838 language: row.get(1)?,
839 kind: row.get(2)?,
840 symbol: row.get(3)?,
841 })
842 })?;
843 for row in rows {
844 surface.push(
845 ImpactCategory::DirectStructural,
846 row?,
847 "same_file_sibling",
848 format!("shares file with {}", target.qualified_name),
849 );
850 }
851 }
852 Ok(())
853}
854
855fn textual_fallback(
856 conn: &Connection,
857 query: &str,
858 surface: &mut ImpactSurface,
859 limit: usize,
860) -> anyhow::Result<()> {
861 if limit == 0 {
862 return Ok(());
863 }
864 let like = format!("%{query}%");
865 let mut stmt = conn.prepare(
866 "
867 SELECT DISTINCT files.path, files.language, files.kind, symbols.qualified_name,
868 CASE
869 WHEN files.path LIKE ?1 THEN 'path LIKE fallback'
870 WHEN symbols.name LIKE ?1 OR symbols.qualified_name LIKE ?1 THEN 'symbol LIKE fallback'
871 ELSE 'chunk text LIKE fallback'
872 END
873 FROM files
874 LEFT JOIN symbols ON symbols.file_id = files.id
875 LEFT JOIN chunks ON chunks.file_id = files.id
876 WHERE files.path LIKE ?1
877 OR symbols.name LIKE ?1
878 OR symbols.qualified_name LIKE ?1
879 OR chunks.text LIKE ?1
880 ORDER BY files.kind, files.path, symbols.qualified_name
881 LIMIT ?2
882 ",
883 )?;
884 let rows = stmt.query_map(params![like, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
885 Ok((
886 FileSymbol {
887 path: row.get(0)?,
888 language: row.get(1)?,
889 kind: row.get(2)?,
890 symbol: row.get(3)?,
891 },
892 row.get::<_, String>(4)?,
893 ))
894 })?;
895 for row in rows {
896 let (file_symbol, evidence) = row?;
897 surface.push(ImpactCategory::ProbableTextual, file_symbol, "textual_fallback", evidence);
898 }
899 Ok(())
900}
901
902fn import_export_items(
903 conn: &Connection,
904 symbol_id: i64,
905 qualified_name: &str,
906 names: &[String],
907 limit: u32,
908) -> anyhow::Result<Vec<ImpactItem>> {
909 let mut items = Vec::new();
910 let mut stmt = conn.prepare(
911 "
912 SELECT files.path, files.language, files.kind, edges.from_name,
913 edges.edge_kind, edges.confidence
914 FROM edges
915 JOIN files ON files.id = edges.source_file_id
916 WHERE edges.edge_kind IN ('imports', 'exports')
917 AND (edges.to_symbol_id = ?1 OR edges.to_name = ?2)
918 ORDER BY files.kind, files.path, edges.edge_kind
919 LIMIT ?3
920 ",
921 )?;
922 for name in std::iter::once(qualified_name).chain(names.iter().map(String::as_str)) {
923 let rows = stmt.query_map(params![symbol_id, name, i64::from(limit)], |row| {
924 impact_item_row(row, "Import/export dependents", "import_export_dependent")
925 })?;
926 items.extend(rows_to_items(rows)?);
927 if items.len() >= usize::try_from(limit).unwrap_or(usize::MAX) {
928 break;
929 }
930 }
931 dedupe_items(&mut items);
932 items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
933 Ok(items)
934}
935
936fn test_items(
937 conn: &Connection,
938 symbol: &SymbolHit,
939 names: &[String],
940 limit: u32,
941) -> anyhow::Result<Vec<ImpactItem>> {
942 let mut items = Vec::new();
943 for name in names_for_like(symbol, names) {
944 items.extend(section_like_items(
945 conn,
946 &name,
947 "Tests touching this symbol/path",
948 "test_mentions_symbol_or_path",
949 "
950 files.kind = 'source'
951 AND (
952 files.path LIKE '%test%'
953 OR files.path LIKE '%spec%'
954 OR chunks.text LIKE '%#[cfg(test)]%'
955 OR chunks.text LIKE '%describe(%'
956 OR chunks.text LIKE '%it(%'
957 OR chunks.text LIKE '%test(%'
958 )
959 ",
960 limit,
961 )?);
962 }
963 let mut items = collapse_by_path(items);
964 items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
965 Ok(items)
966}
967
968fn docs_items(
969 conn: &Connection,
970 symbol: &SymbolHit,
971 names: &[String],
972 limit: u32,
973) -> anyhow::Result<Vec<ImpactItem>> {
974 let mut items = Vec::new();
975 for name in names_for_like(symbol, names) {
976 items.extend(section_like_items(
977 conn,
978 &name,
979 "Docs mentioning symbol/path",
980 "docs_mentions_symbol_or_path",
981 "files.kind = 'docs'",
982 limit,
983 )?);
984 }
985 let mut items = collapse_by_path(items);
986 items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
987 Ok(items)
988}
989
990fn text_fallback_items(
991 conn: &Connection,
992 symbol: &SymbolHit,
993 names: &[String],
994 limit: u32,
995) -> anyhow::Result<Vec<ImpactItem>> {
996 let mut items = Vec::new();
997 for name in names_for_like(symbol, names) {
998 items.extend(section_like_items(
999 conn,
1000 &name,
1001 "Text fallback hits",
1002 "text_fallback",
1003 "1 = 1",
1004 limit,
1005 )?);
1006 }
1007 let mut items = collapse_by_path(items);
1008 items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
1009 Ok(items)
1010}
1011
1012fn names_for_like(symbol: &SymbolHit, names: &[String]) -> Vec<String> {
1013 let mut out = BTreeSet::new();
1014 out.insert(symbol.name.clone());
1015 out.insert(symbol.qualified_name.clone());
1016 out.insert(symbol.path.clone());
1017 for name in names {
1018 out.insert(name.clone());
1019 }
1020 out.into_iter().collect()
1021}
1022
1023fn section_like_items(
1024 conn: &Connection,
1025 needle: &str,
1026 category: &str,
1027 reason: &str,
1028 filter: &str,
1029 limit: u32,
1030) -> anyhow::Result<Vec<ImpactItem>> {
1031 let like = format!("%{needle}%");
1032 let sql = format!(
1038 "
1039 SELECT files.path, files.language, files.kind,
1040 MAX(CASE WHEN symbols.name LIKE ?1 OR symbols.qualified_name LIKE ?1
1041 THEN symbols.qualified_name END) AS matched_symbol,
1042 MAX(CASE WHEN files.path LIKE ?1 THEN 1 ELSE 0 END) AS path_match
1043 FROM files
1044 LEFT JOIN symbols ON symbols.file_id = files.id
1045 LEFT JOIN chunks ON chunks.file_id = files.id
1046 WHERE ({filter})
1047 AND (
1048 files.path LIKE ?1
1049 OR symbols.name LIKE ?1
1050 OR symbols.qualified_name LIKE ?1
1051 OR chunks.text LIKE ?1
1052 )
1053 GROUP BY files.path, files.language, files.kind
1054 ORDER BY files.kind, files.path
1055 LIMIT ?2
1056 "
1057 );
1058 let mut stmt = conn.prepare(&sql)?;
1059 let rows = stmt.query_map(params![like, i64::from(limit)], |row| {
1060 let matched_symbol: Option<String> = row.get(3)?;
1061 let path_match: i64 = row.get(4)?;
1062 let (symbol, match_kind) = if path_match == 1 {
1068 (None, "path match")
1069 } else if let Some(symbol) = matched_symbol {
1070 (Some(symbol), "symbol match")
1071 } else {
1072 (None, "chunk text match")
1073 };
1074 Ok(ImpactItem {
1075 path: row.get(0)?,
1076 language: row.get(1)?,
1077 kind: row.get(2)?,
1078 symbol,
1079 category: category.to_string(),
1080 reason: reason.to_string(),
1081 evidence: vec![format!("{match_kind} for `{needle}`")],
1082 })
1083 })?;
1084 rows_to_items(rows)
1085}
1086
1087fn git_commit_items(
1088 conn: &Connection,
1089 paths: &[String],
1090 limit: u32,
1091) -> anyhow::Result<Vec<ImpactItem>> {
1092 let mut surface = ImpactSurface::default();
1093 git_commits_for_paths(conn, paths, &mut surface, usize::try_from(limit).unwrap_or(usize::MAX))?;
1094 Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1095}
1096
1097fn github_ref_items(
1098 conn: &Connection,
1099 paths: &[String],
1100 limit: u32,
1101) -> anyhow::Result<Vec<ImpactItem>> {
1102 let mut surface = ImpactSurface::default();
1103 github_refs_for_paths(conn, paths, &mut surface, usize::try_from(limit).unwrap_or(usize::MAX))?;
1104 Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1105}
1106
1107fn github_rationale_items(
1108 conn: &Connection,
1109 query: &str,
1110 limit: u32,
1111) -> anyhow::Result<Vec<ImpactItem>> {
1112 let mut surface = ImpactSurface::default();
1113 github_rationale_for_query(
1114 conn,
1115 query,
1116 &mut surface,
1117 usize::try_from(limit).unwrap_or(usize::MAX),
1118 )?;
1119 Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1120}
1121
1122fn impact_item_row(
1123 row: &rusqlite::Row<'_>,
1124 category: &'static str,
1125 reason: &'static str,
1126) -> rusqlite::Result<ImpactItem> {
1127 Ok(ImpactItem {
1128 path: row.get(0)?,
1129 language: row.get(1)?,
1130 kind: row.get(2)?,
1131 symbol: row.get(3)?,
1132 category: category.to_string(),
1133 reason: reason.to_string(),
1134 evidence: vec![format!("{} edge ({})", row.get::<_, String>(4)?, row.get::<_, String>(5)?)],
1135 })
1136}
1137
1138fn collapse_by_path(items: Vec<ImpactItem>) -> Vec<ImpactItem> {
1143 use std::collections::btree_map::Entry;
1144
1145 let mut by_path: BTreeMap<String, ImpactItem> = BTreeMap::new();
1146 for item in items {
1147 match by_path.entry(item.path.clone()) {
1148 Entry::Vacant(slot) => {
1149 slot.insert(item);
1150 },
1151 Entry::Occupied(mut slot) => {
1152 if slot.get().symbol.is_none() && item.symbol.is_some() {
1153 slot.insert(item);
1154 }
1155 },
1156 }
1157 }
1158 by_path.into_values().collect()
1159}
1160
1161fn dedupe_items(items: &mut Vec<ImpactItem>) {
1162 let mut seen = BTreeSet::new();
1163 items.retain(|item| {
1164 seen.insert((
1165 item.category.clone(),
1166 item.path.clone(),
1167 item.symbol.clone(),
1168 item.reason.clone(),
1169 ))
1170 });
1171}
1172
1173fn parser_failure_count(conn: &Connection) -> anyhow::Result<u64> {
1174 let count: i64 =
1175 conn.query_row("SELECT COUNT(*) FROM parser_failures", [], |row| row.get(0))?;
1176 Ok(u64::try_from(count).unwrap_or(0))
1177}
1178
1179fn historical_evidence(
1180 conn: &Connection,
1181 paths: &[String],
1182 query: &str,
1183 surface: &mut ImpactSurface,
1184 limit: usize,
1185) -> anyhow::Result<()> {
1186 if paths.is_empty() || surface.len() >= limit {
1187 return Ok(());
1188 }
1189 git_commits_for_paths(conn, paths, surface, limit.saturating_sub(surface.len()))?;
1190 if surface.len() >= limit {
1191 return Ok(());
1192 }
1193 github_refs_for_paths(conn, paths, surface, limit.saturating_sub(surface.len()))?;
1194 if surface.len() >= limit {
1195 return Ok(());
1196 }
1197 github_rationale_for_query(conn, query, surface, limit.saturating_sub(surface.len()))?;
1198 Ok(())
1199}
1200
1201fn git_commits_for_paths(
1202 conn: &Connection,
1203 paths: &[String],
1204 surface: &mut ImpactSurface,
1205 limit: usize,
1206) -> anyhow::Result<()> {
1207 let mut remaining = limit;
1208 let mut stmt = conn.prepare(
1209 "
1210 SELECT files.path, files.language, files.kind,
1211 git_commits.hash, git_commits.subject, git_commits.authored_at_s
1212 FROM git_file_changes
1213 JOIN git_commits ON git_commits.hash = git_file_changes.commit_hash
1214 LEFT JOIN files ON files.path = git_file_changes.path
1215 WHERE git_file_changes.path = ?1
1216 ORDER BY git_commits.authored_at_s DESC, git_commits.hash
1217 LIMIT ?2
1218 ",
1219 )?;
1220 for path in paths {
1221 if remaining == 0 {
1222 break;
1223 }
1224 let file = file_for_path(conn, path)?;
1225 let rows =
1226 stmt.query_map(params![path, i64::try_from(remaining).unwrap_or(i64::MAX)], |row| {
1227 Ok((
1228 row.get::<_, Option<String>>(0)?,
1229 row.get::<_, Option<String>>(1)?,
1230 row.get::<_, Option<String>>(2)?,
1231 row.get::<_, String>(3)?,
1232 row.get::<_, String>(4)?,
1233 row.get::<_, i64>(5)?,
1234 ))
1235 })?;
1236 for row in rows {
1237 let (row_path, language, kind, hash, subject, authored_at_s) = row?;
1238 let file_symbol = FileSymbol {
1239 path: row_path.unwrap_or_else(|| file.path.clone()),
1240 language: language.unwrap_or_else(|| file.language.clone()),
1241 kind: kind.unwrap_or_else(|| file.kind.clone()),
1242 symbol: None,
1243 };
1244 surface.push(
1245 ImpactCategory::HistoricalPapertrail,
1246 file_symbol,
1247 "git_commit_touched_file",
1248 format!("{} touched {path} at {authored_at_s}: {subject}", short_hash(&hash)),
1249 );
1250 remaining = remaining.saturating_sub(1);
1251 if remaining == 0 {
1252 break;
1253 }
1254 }
1255 }
1256 Ok(())
1257}
1258
1259fn github_refs_for_paths(
1260 conn: &Connection,
1261 paths: &[String],
1262 surface: &mut ImpactSurface,
1263 limit: usize,
1264) -> anyhow::Result<()> {
1265 let mut remaining = limit;
1266 let mut stmt = conn.prepare(
1267 "
1268 SELECT owner, repo, number, ref_kind, source_kind, source_text
1269 FROM github_refs
1270 WHERE source_path = ?1
1271 ORDER BY id DESC
1272 LIMIT ?2
1273 ",
1274 )?;
1275 for path in paths {
1276 if remaining == 0 {
1277 break;
1278 }
1279 let file = file_for_path(conn, path)?;
1280 let rows =
1281 stmt.query_map(params![path, i64::try_from(remaining).unwrap_or(i64::MAX)], |row| {
1282 Ok((
1283 row.get::<_, String>(0)?,
1284 row.get::<_, String>(1)?,
1285 row.get::<_, i64>(2)?,
1286 row.get::<_, String>(3)?,
1287 row.get::<_, String>(4)?,
1288 row.get::<_, String>(5)?,
1289 ))
1290 })?;
1291 for row in rows {
1292 let (owner, repo, number, ref_kind, source_kind, source_text) = row?;
1293 surface.push(
1294 ImpactCategory::HistoricalPapertrail,
1295 file.clone(),
1296 "github_papertrail",
1297 format!("{owner}/{repo}#{number} {ref_kind}/{source_kind}: {source_text}"),
1298 );
1299 remaining = remaining.saturating_sub(1);
1300 if remaining == 0 {
1301 break;
1302 }
1303 }
1304 }
1305 Ok(())
1306}
1307
1308fn github_rationale_for_query(
1309 conn: &Connection,
1310 query: &str,
1311 surface: &mut ImpactSurface,
1312 limit: usize,
1313) -> anyhow::Result<()> {
1314 let fts_query = fts_escape(query);
1315 if fts_query.is_empty() {
1316 return Ok(());
1317 }
1318 let mut stmt = conn.prepare(
1319 "
1320 SELECT url, title, classification
1321 FROM github_fts
1322 WHERE github_fts MATCH ?1
1323 ORDER BY rank
1324 LIMIT ?2
1325 ",
1326 )?;
1327 let rows = stmt
1328 .query_map(params![fts_query, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
1329 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
1330 })?;
1331 for row in rows {
1332 let (url, title, classification) = row?;
1333 surface.push(
1334 ImpactCategory::HistoricalPapertrail,
1335 FileSymbol {
1336 path: "(github papertrail)".to_string(),
1337 language: "github".to_string(),
1338 kind: "papertrail".to_string(),
1339 symbol: None,
1340 },
1341 "github_papertrail",
1342 format!("{classification}: {title} ({url})"),
1343 );
1344 }
1345 Ok(())
1346}
1347
1348fn file_for_path(conn: &Connection, path: &str) -> anyhow::Result<FileSymbol> {
1349 let row = conn
1350 .query_row("SELECT path, language, kind FROM files WHERE path = ?1", [path], |row| {
1351 Ok(FileSymbol {
1352 path: row.get(0)?,
1353 language: row.get(1)?,
1354 kind: row.get(2)?,
1355 symbol: None,
1356 })
1357 })
1358 .optional()?;
1359 Ok(row.unwrap_or_else(|| FileSymbol {
1360 path: path.to_string(),
1361 language: "unknown".to_string(),
1362 kind: "historical".to_string(),
1363 symbol: None,
1364 }))
1365}
1366
1367fn short_hash(hash: &str) -> &str {
1368 hash.get(..12).unwrap_or(hash)
1369}
1370
1371fn fts_escape(query: &str) -> String {
1372 query
1373 .split_whitespace()
1374 .filter(|part| !part.is_empty())
1375 .map(|part| format!("\"{}\"", part.replace('"', "\"\"")))
1376 .collect::<Vec<_>>()
1377 .join(" OR ")
1378}
1379
1380fn rows_to_items(
1381 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<ImpactItem>>,
1382) -> anyhow::Result<Vec<ImpactItem>> {
1383 let mut items = Vec::new();
1384 for row in rows {
1385 items.push(row?);
1386 }
1387 Ok(items)
1388}
1389
1390fn collect_rows<T>(
1391 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>>,
1392) -> anyhow::Result<Vec<T>> {
1393 let mut out = Vec::new();
1394 for row in rows {
1395 out.push(row?);
1396 }
1397 Ok(out)
1398}