1use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node};
9use crate::core::semantic::{EdgeEvidence, EvidenceGrade, EvidenceOrigin};
10use std::collections::{BTreeMap, HashSet};
11use std::path::Path;
12
13#[derive(Debug, Clone)]
18pub(crate) struct CommitInfo {
19 pub hash: String,
20 pub short_hash: String,
21 pub author: String,
22 pub date: String,
23 pub message: String,
24 pub files_changed: Vec<String>,
25}
26
27pub(crate) fn index_git_history(
28 graph: &CodeGraph,
29 project_root: &Path,
30 max_commits: usize,
31) -> anyhow::Result<EnrichmentStats> {
32 let mut stats = EnrichmentStats::default();
33
34 let output = std::process::Command::new("git")
35 .args([
36 "log",
37 &format!("-{max_commits}"),
38 "--format=%H%n%h%n%an%n%ai%n%s",
39 "--name-only",
40 ])
41 .current_dir(project_root)
42 .output();
43
44 let output = match output {
45 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
46 _ => return Ok(stats),
47 };
48
49 let commits = parse_git_log(&output);
50 for commit in &commits {
51 let commit_node =
52 Node::commit(&commit.short_hash, &commit.message).with_metadata(&format!(
53 "{{\"author\":\"{}\",\"date\":\"{}\",\"hash\":\"{}\"}}",
54 commit.author, commit.date, commit.hash
55 ));
56
57 let commit_id = graph.upsert_node(&commit_node)?;
58 stats.commits_indexed += 1;
59
60 for file in &commit.files_changed {
61 if let Some(file_node) = graph.get_node_by_path(file)?
62 && let Some(file_id) = file_node.id
63 {
64 graph.upsert_edge(&Edge::new(file_id, commit_id, EdgeKind::ChangedIn))?;
65 stats.edges_created += 1;
66 }
67 }
68 }
69
70 Ok(stats)
71}
72
73fn parse_git_log(output: &str) -> Vec<CommitInfo> {
74 let mut commits = Vec::new();
75 let mut lines = output.lines().peekable();
76
77 while lines.peek().is_some() {
78 let hash = match lines.next() {
79 Some(h) if !h.is_empty() && h.len() >= 7 => h.to_string(),
80 _ => {
81 lines.next();
82 continue;
83 }
84 };
85
86 let short_hash = match lines.next() {
87 Some(s) => s.to_string(),
88 None => break,
89 };
90 let author = match lines.next() {
91 Some(a) => a.to_string(),
92 None => break,
93 };
94 let date = match lines.next() {
95 Some(d) => d.to_string(),
96 None => break,
97 };
98 let message = match lines.next() {
99 Some(m) => m.to_string(),
100 None => break,
101 };
102
103 let mut files_changed = Vec::new();
104 while let Some(line) = lines.peek() {
105 if line.is_empty() {
106 lines.next();
107 break;
108 }
109 files_changed.push(line.to_string());
110 lines.next();
111 }
112
113 commits.push(CommitInfo {
114 hash,
115 short_hash,
116 author,
117 date,
118 message,
119 files_changed,
120 });
121 }
122
123 commits
124}
125
126const TEST_PATTERNS: &[&str] = &[
131 "_test.",
132 "test_",
133 ".test.",
134 ".spec.",
135 "_spec.",
136 "tests/",
137 "__tests__/",
138];
139
140pub(crate) fn index_tests(
141 graph: &CodeGraph,
142 project_root: &Path,
143) -> anyhow::Result<EnrichmentStats> {
144 let mut stats = EnrichmentStats::default();
145
146 let output = std::process::Command::new("git")
147 .args(["ls-files"])
148 .current_dir(project_root)
149 .output();
150
151 let files: Vec<String> = match output {
152 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
153 .lines()
154 .map(ToString::to_string)
155 .collect(),
156 _ => return Ok(stats),
157 };
158
159 for file in &files {
160 if !is_test_file(file) {
161 continue;
162 }
163
164 let test_node = Node::test(file, file);
165 let test_id = graph.upsert_node(&test_node)?;
166 stats.tests_indexed += 1;
167
168 let tested_file = infer_tested_file(file);
169 if let Some(ref tested) = tested_file
170 && files.contains(tested)
171 {
172 let target_node = graph.get_node_by_path(tested)?;
173 if let Some(target) = target_node {
174 if let Some(target_id) = target.id {
175 graph.upsert_edge(&Edge::new(target_id, test_id, EdgeKind::TestedBy))?;
176 stats.edges_created += 1;
177 }
178 } else {
179 let file_id = graph.upsert_node(&Node::file(tested))?;
180 graph.upsert_edge(&Edge::new(file_id, test_id, EdgeKind::TestedBy))?;
181 stats.edges_created += 1;
182 }
183 }
184 }
185
186 Ok(stats)
187}
188
189fn is_test_file(path: &str) -> bool {
190 let lower = path.to_lowercase();
191 TEST_PATTERNS.iter().any(|p| lower.contains(p))
192}
193
194fn infer_tested_file(test_path: &str) -> Option<String> {
195 let name = Path::new(test_path).file_name()?.to_str()?;
196
197 for pattern in &["_test.", ".test.", "_spec.", ".spec."] {
198 if let Some(pos) = name.find(pattern) {
199 let base = &name[..pos];
200 let ext = &name[pos + pattern.len() - 1..];
201 let parent = Path::new(test_path).parent()?;
202
203 let candidate = parent.join(format!("{base}{ext}"));
204 if let Some(s) = candidate.to_str() {
205 return Some(s.replace('\\', "/"));
206 }
207
208 if let Some(pp) = parent.parent() {
209 let src_candidate = pp.join("src").join(format!("{base}{ext}"));
210 if let Some(s) = src_candidate.to_str() {
211 return Some(s.replace('\\', "/"));
212 }
213 }
214 }
215 }
216
217 if let Some(base) = name.strip_prefix("test_") {
218 let parent = Path::new(test_path).parent()?;
219 let candidate = parent.join(base);
220 return candidate.to_str().map(|s| s.replace('\\', "/"));
221 }
222
223 None
224}
225
226pub(crate) fn index_knowledge(
231 graph: &CodeGraph,
232 project_root: &str,
233) -> anyhow::Result<EnrichmentStats> {
234 let mut stats = EnrichmentStats::default();
235
236 let knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root);
237 let Some(knowledge) = knowledge else {
238 return Ok(stats);
239 };
240
241 let mut mentioned_files: HashSet<String> = HashSet::new();
242
243 let deliverable = knowledge.delivery_filter();
247 for fact in knowledge.facts.iter().filter(|&fact| deliverable(fact)) {
248 let node = Node::knowledge(&fact.key, &format!("[{}] {}", fact.category, fact.value));
249 let knowledge_id = graph.upsert_node(&node)?;
250 stats.knowledge_indexed += 1;
251
252 for file_ref in extract_file_refs(&fact.value) {
253 if mentioned_files.insert(format!("{}:{}", fact.key, file_ref))
254 && let Some(file_node) = graph.get_node_by_path(&file_ref)?
255 && let Some(file_id) = file_node.id
256 {
257 graph.upsert_edge(&Edge::new(file_id, knowledge_id, EdgeKind::MentionedIn))?;
258 stats.edges_created += 1;
259 }
260 }
261 }
262
263 Ok(stats)
264}
265
266fn extract_file_refs(text: &str) -> Vec<String> {
267 let mut refs = Vec::new();
268 for word in text.split_whitespace() {
269 let cleaned = word.trim_matches(|c: char| c == '`' || c == '\'' || c == '"' || c == ',');
270 if looks_like_file_path(cleaned) {
271 refs.push(cleaned.to_string());
272 }
273 }
274 refs
275}
276
277fn looks_like_file_path(s: &str) -> bool {
278 if s.len() < 4 || s.len() > 200 {
279 return false;
280 }
281 let path = Path::new(s);
282 let has_sep = s.contains('/') || s.contains('\\');
283 match path.extension().and_then(|e| e.to_str()) {
284 Some(ext) => {
285 let ext_lower = ext.to_ascii_lowercase();
286 has_sep
287 || matches!(
288 ext_lower.as_str(),
289 "rs" | "ts"
290 | "py"
291 | "js"
292 | "go"
293 | "java"
294 | "tsx"
295 | "jsx"
296 | "rb"
297 | "c"
298 | "cpp"
299 | "h"
300 | "cs"
301 | "swift"
302 | "kt"
303 )
304 }
305 None => false,
306 }
307}
308
309#[derive(Debug, Default)]
314pub struct EnrichmentStats {
315 pub commits_indexed: usize,
316 pub tests_indexed: usize,
317 pub knowledge_indexed: usize,
318 pub edges_created: usize,
319 pub semantic: crate::core::semantic::EscalationStats,
321}
322
323impl EnrichmentStats {
324 pub(crate) fn merge(&mut self, other: &Self) {
325 self.commits_indexed += other.commits_indexed;
326 self.tests_indexed += other.tests_indexed;
327 self.knowledge_indexed += other.knowledge_indexed;
328 self.edges_created += other.edges_created;
329 let (s, o) = (&mut self.semantic, &other.semantic);
330 s.candidates += o.candidates;
331 s.cache_hits += o.cache_hits;
332 s.live_queries += o.live_queries;
333 s.verified += o.verified;
334 s.not_in_project += o.not_in_project;
335 s.unresolved += o.unresolved;
336 }
337
338 pub fn format_summary(&self) -> String {
339 let mut out = format!(
340 "Graph enriched: {} commits, {} tests, {} knowledge entries, {} edges",
341 self.commits_indexed, self.tests_indexed, self.knowledge_indexed, self.edges_created
342 );
343 let s = &self.semantic;
344 if s.candidates > 0 {
345 out.push_str(&format!(
346 "\nSemantic: {} uncertain call sites → {} verified, {} outside project, {} unresolved ({} cached, {} live queries)",
347 s.candidates, s.verified, s.not_in_project, s.unresolved, s.cache_hits, s.live_queries
348 ));
349 }
350 out
351 }
352}
353
354pub fn enrich_graph(
355 graph: &CodeGraph,
356 project_root: &Path,
357 max_commits: usize,
358) -> anyhow::Result<EnrichmentStats> {
359 let mut total = EnrichmentStats::default();
360
361 let git_stats = index_git_history(graph, project_root, max_commits)?;
362 total.merge(&git_stats);
363
364 let test_stats = index_tests(graph, project_root)?;
365 total.merge(&test_stats);
366
367 if let Some(root_str) = project_root.to_str() {
368 let knowledge_stats = index_knowledge(graph, root_str)?;
369 total.merge(&knowledge_stats);
370
371 let callgraph_stats = consolidate_callgraph(graph, root_str)?;
372 total.merge(&callgraph_stats);
373 }
374
375 Ok(total)
376}
377
378pub(crate) fn refresh_semantic_edges_in_background(project_root: &str) {
384 use crate::core::config::SemanticMode;
385 let mode = SemanticMode::for_project(project_root);
386 let worthwhile = match mode {
387 SemanticMode::Off => false,
388 SemanticMode::Auto => crate::lsp::router::has_live_backend(project_root),
389 SemanticMode::Eager => true,
390 };
391 if !worthwhile {
392 return;
393 }
394 match CodeGraph::open(project_root) {
395 Ok(graph) => {
396 if let Err(e) = consolidate_callgraph(&graph, project_root) {
397 tracing::warn!("[semantic] call-edge refresh failed for {project_root}: {e}");
398 }
399 }
400 Err(e) => tracing::debug!("[semantic] no property graph for {project_root}: {e}"),
401 }
402}
403
404const IDLE_BEFORE_REFRESH: std::time::Duration = std::time::Duration::from_secs(5);
406
407pub struct ToolActivity;
411
412static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
413static LAST_END_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
415static ACTIVITY_EPOCH: std::sync::LazyLock<std::time::Instant> =
416 std::sync::LazyLock::new(std::time::Instant::now);
417
418pub struct ToolActivityGuard;
420
421impl ToolActivity {
422 pub fn begin() -> ToolActivityGuard {
423 IN_FLIGHT.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
424 ToolActivityGuard
425 }
426
427 fn quiet_for(quiet: std::time::Duration) -> bool {
429 use std::sync::atomic::Ordering;
430 let now = u64::try_from(ACTIVITY_EPOCH.elapsed().as_millis()).unwrap_or(u64::MAX);
431 let quiet_ms = u64::try_from(quiet.as_millis()).unwrap_or(u64::MAX);
432 IN_FLIGHT.load(Ordering::Acquire) == 0
433 && now.saturating_sub(LAST_END_MS.load(Ordering::Acquire)) >= quiet_ms
434 }
435}
436
437impl Drop for ToolActivityGuard {
438 fn drop(&mut self) {
439 use std::sync::atomic::Ordering;
440 let now = u64::try_from(ACTIVITY_EPOCH.elapsed().as_millis()).unwrap_or(u64::MAX);
441 LAST_END_MS.store(now, Ordering::Release);
442 IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
443 }
444}
445
446const BACKEND_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
449const BACKEND_REFRESH_RETRY: std::time::Duration = std::time::Duration::from_secs(30);
451
452pub(crate) fn schedule_semantic_refresh(project_root: &str) {
461 use std::collections::HashMap;
462 use std::sync::{LazyLock, Mutex, PoisonError};
463 use std::time::{Duration, Instant};
464 static NEXT: LazyLock<Mutex<HashMap<String, Instant>>> =
466 LazyLock::new(|| Mutex::new(HashMap::new()));
467 let defer = |root: &str, by: Duration| {
468 NEXT.lock()
469 .unwrap_or_else(PoisonError::into_inner)
470 .insert(root.to_string(), Instant::now() + by);
471 };
472
473 if cfg!(test) {
475 return;
476 }
477 let root = crate::core::index_paths::normalize_project_root(project_root);
478 {
479 let now = Instant::now();
480 let mut next = NEXT.lock().unwrap_or_else(PoisonError::into_inner);
481 if next.get(&root).is_some_and(|t| now < *t) {
482 return;
483 }
484 next.retain(|_, t| now < *t);
487 next.insert(root.clone(), now + BACKEND_REFRESH_INTERVAL);
489 }
490 let worker_root = root.clone();
491 let spawned = std::thread::Builder::new()
492 .name("leanctx-semantic-refresh".into())
493 .spawn(move || {
494 let root = worker_root;
495 let give_up = Instant::now() + Duration::from_mins(2);
496 loop {
497 std::thread::sleep(Duration::from_secs(2));
498 if !crate::lsp::router::backend_busy(&root)
501 && ToolActivity::quiet_for(IDLE_BEFORE_REFRESH)
502 {
503 break;
504 }
505 if Instant::now() > give_up {
506 return defer(&root, BACKEND_REFRESH_RETRY);
507 }
508 }
509 let ready = !crate::core::property_graph::engine_outdated(&root)
510 && CodeGraph::open(&root).is_ok_and(|g| g.node_count().unwrap_or(0) > 0);
511 if !ready {
512 return defer(&root, BACKEND_REFRESH_RETRY);
513 }
514 refresh_semantic_edges_in_background(&root);
515 defer(&root, BACKEND_REFRESH_INTERVAL);
516 });
517 if spawned.is_err() {
518 NEXT.lock()
519 .unwrap_or_else(PoisonError::into_inner)
520 .remove(&root);
521 }
522}
523
524fn consolidate_callgraph(graph: &CodeGraph, project_root: &str) -> anyhow::Result<EnrichmentStats> {
525 use crate::core::call_graph::{CallGraph, CallGraphInputs, resolve_edge_callee_targets};
526 use crate::core::semantic::escalate_calls;
527 use crate::core::semantic::relations::{Relation, RelationBudget, resolve_relation_edges};
528
529 let started = std::time::Instant::now();
530 let inputs = CallGraphInputs::open(project_root);
531 let call_graph = CallGraph::load_or_build(project_root, &inputs);
532 let structural = resolve_edge_callee_targets(&inputs, &call_graph.edges);
533 tracing::debug!(
534 target: "lean_ctx::semantic",
535 "consolidate: call graph of {} files, {} edges ready in {} ms",
536 call_graph.file_hashes.len(),
537 call_graph.edges.len(),
538 started.elapsed().as_millis()
539 );
540 let mode = crate::core::config::SemanticMode::for_project(project_root);
541 let _ = graph.semantic_prune(&call_graph.file_hashes);
543 let escalation = escalate_calls(
544 graph,
545 project_root,
546 &inputs,
547 &call_graph.edges,
548 &structural,
549 &call_graph.file_hashes,
550 mode,
551 crate::core::semantic::EscalationBudget::BACKGROUND,
552 );
553 let mut stats = consolidate_call_edges(graph, &call_graph.edges, &structural, &escalation)?;
554 stats.semantic = escalation.stats;
555
556 let pass = |relation| {
557 resolve_relation_edges(
558 graph,
559 project_root,
560 &inputs,
561 &call_graph.file_hashes,
562 mode,
563 relation,
564 None,
565 RelationBudget::BACKGROUND,
566 )
567 };
568 let implements = pass(Relation::Implements);
569 stats.merge(&apply_relation_edges(
570 graph,
571 Relation::Implements,
572 &implements,
573 |_, _| false,
574 )?);
575 let implemented: std::collections::BTreeSet<(&str, &str)> = implements
578 .edges
579 .iter()
580 .map(|e| (e.from.as_str(), e.to.as_str()))
581 .collect();
582 stats.merge(&apply_relation_edges(
583 graph,
584 Relation::Extends,
585 &pass(Relation::Extends),
586 |from, to| implemented.contains(&(from, to)),
587 )?);
588 tracing::debug!(
589 target: "lean_ctx::semantic",
590 "consolidate: done in {} ms",
591 started.elapsed().as_millis()
592 );
593 Ok(stats)
594}
595
596type PairEvidence = (EvidenceGrade, Option<String>, u32);
599
600fn consolidate_call_edges(
613 graph: &CodeGraph,
614 edges: &[crate::core::call_graph::CallEdge],
615 structural: &[crate::core::call_graph::StructuralTarget],
616 escalation: &crate::core::semantic::Escalation,
617) -> anyhow::Result<EnrichmentStats> {
618 use crate::core::call_graph::StructuralTarget;
619 use crate::core::semantic::SemanticVerdict;
620
621 let mut stats = EnrichmentStats::default();
622 let mut pairs: BTreeMap<(&str, String), PairEvidence> = BTreeMap::new();
623 let unsettled_callers: std::collections::BTreeSet<&str> = edges
624 .iter()
625 .zip(&escalation.unsettled)
626 .filter(|(_, unsettled)| **unsettled)
627 .map(|(e, _)| e.caller_file.as_str())
628 .collect();
629 for ((edge, target), verdict) in edges.iter().zip(structural).zip(&escalation.verdicts) {
630 let resolved = match verdict {
631 Some(SemanticVerdict::Verified { file, backend }) => Some((
632 file.clone(),
633 EvidenceGrade::VerifiedSemantic,
634 Some(backend.clone()),
635 )),
636 Some(SemanticVerdict::NotInProject) => None,
637 None => match target {
638 StructuralTarget::Resolved { file, via } => {
639 Some((file.clone(), EvidenceGrade::from_scope(*via), None))
640 }
641 StructuralTarget::Ambiguous | StructuralTarget::Unknown => None,
642 },
643 };
644 let Some((to, grade, backend)) = resolved else {
645 continue;
646 };
647 if to == edge.caller_file {
648 continue;
649 }
650 let slot =
651 pairs
652 .entry((edge.caller_file.as_str(), to))
653 .or_insert((grade, backend.clone(), 0));
654 slot.2 += 1;
655 if grade > slot.0 || (grade == slot.0 && backend < slot.1 && backend.is_some()) {
656 slot.0 = grade;
657 slot.1 = backend;
658 }
659 }
660
661 for ((from_file, to_file), (grade, backend, sites)) in &pairs {
662 let evidence =
663 EdgeEvidence::new(*grade, EvidenceOrigin::Enrichment, backend.clone(), *sites);
664 if let (Some(from_id), Some(to_id)) = (
665 file_node_id(graph, from_file)?,
666 file_node_id(graph, to_file)?,
667 ) {
668 graph.upsert_edge_with_evidence(from_id, to_id, &EdgeKind::Calls, &evidence)?;
669 stats.edges_created += 1;
670 }
671 }
672
673 withdraw_own_file_edges(graph, &EdgeKind::Calls, |s, t| {
674 pairs.contains_key(&(s, t.to_string())) || unsettled_callers.contains(s)
675 })?;
676 Ok(stats)
677}
678
679pub fn apply_relation_edges(
683 graph: &CodeGraph,
684 relation: crate::core::semantic::relations::Relation,
685 pass: &crate::core::semantic::relations::RelationPass,
686 skip: impl Fn(&str, &str) -> bool,
687) -> anyhow::Result<EnrichmentStats> {
688 let kind = relation.edge_kind();
689 let mut stats = EnrichmentStats::default();
690 let mut live: std::collections::BTreeSet<(&str, &str)> = std::collections::BTreeSet::new();
691 for e in pass.edges.iter().filter(|e| !skip(&e.from, &e.to)) {
692 let evidence = EdgeEvidence::new(
693 EvidenceGrade::VerifiedSemantic,
694 EvidenceOrigin::Enrichment,
695 Some(e.backend.clone()),
696 1,
697 );
698 if let (Some(from_id), Some(to_id)) =
699 (file_node_id(graph, &e.from)?, file_node_id(graph, &e.to)?)
700 {
701 graph.upsert_edge_with_evidence(from_id, to_id, &kind, &evidence)?;
702 stats.edges_created += 1;
703 }
704 live.insert((e.from.as_str(), e.to.as_str()));
705 }
706 withdraw_own_file_edges(graph, &kind, |s, t| {
707 live.contains(&(s, t)) || !pass.may_prune(relation, s, t)
708 })?;
709 Ok(stats)
710}
711
712fn file_node_id(graph: &CodeGraph, path: &str) -> anyhow::Result<Option<i64>> {
713 Ok(graph.get_node_by_path(path)?.and_then(|n| n.id))
714}
715
716fn withdraw_own_file_edges(
720 graph: &CodeGraph,
721 kind: &EdgeKind,
722 keep: impl Fn(&str, &str) -> bool,
723) -> anyhow::Result<()> {
724 for (source, target, metadata) in graph.file_edges_of_kind(kind)? {
725 let own = EdgeEvidence::from_metadata(metadata.as_deref())
726 .is_some_and(|e| e.has(EvidenceOrigin::Enrichment));
727 if own && !keep(&source, &target) {
728 graph.withdraw_file_edge(&source, &target, kind, EvidenceOrigin::Enrichment)?;
729 }
730 }
731 Ok(())
732}
733
734#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::core::property_graph::NodeKind;
742
743 #[test]
744 fn parse_git_log_basic() {
745 let log = "abc1234567890abcdef1234567890abcdef12345678\nabc1234\nJohn Doe\n2026-04-28 12:00:00 +0200\nfeat: add feature\nsrc/main.rs\nsrc/lib.rs\n\n";
746 let commits = parse_git_log(log);
747 assert_eq!(commits.len(), 1);
748 assert_eq!(commits[0].short_hash, "abc1234");
749 assert_eq!(commits[0].author, "John Doe");
750 assert_eq!(commits[0].files_changed.len(), 2);
751 }
752
753 #[test]
754 fn parse_git_log_multiple() {
755 let log = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\na1b2c3d\nAlice\n2026-04-27\nfirst\nfile1.rs\n\nf6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5\nf6e5d4c\nBob\n2026-04-28\nsecond\nfile2.rs\nfile3.rs\n\n";
756 let commits = parse_git_log(log);
757 assert_eq!(commits.len(), 2);
758 assert_eq!(commits[1].files_changed.len(), 2);
759 }
760
761 #[test]
762 fn is_test_file_detection() {
763 assert!(is_test_file("src/utils_test.rs"));
764 assert!(is_test_file("tests/integration.rs"));
765 assert!(is_test_file("src/component.test.ts"));
766 assert!(is_test_file("src/component.spec.js"));
767 assert!(is_test_file("__tests__/app.js"));
768 assert!(!is_test_file("src/main.rs"));
769 assert!(!is_test_file("src/utils.rs"));
770 }
771
772 #[test]
773 fn infer_tested_file_from_test() {
774 assert_eq!(
775 infer_tested_file("src/utils_test.rs"),
776 Some("src/utils.rs".to_string())
777 );
778 assert_eq!(
779 infer_tested_file("src/component.test.ts"),
780 Some("src/component.ts".to_string())
781 );
782 assert_eq!(
783 infer_tested_file("src/app.spec.js"),
784 Some("src/app.js".to_string())
785 );
786 }
787
788 #[test]
789 fn infer_tested_file_prefix() {
790 assert_eq!(
791 infer_tested_file("tests/test_parser.py"),
792 Some("tests/parser.py".to_string())
793 );
794 }
795
796 #[test]
797 fn looks_like_file_path_detection() {
798 assert!(looks_like_file_path("src/main.rs"));
799 assert!(looks_like_file_path("core/utils.ts"));
800 assert!(looks_like_file_path("main.py"));
801 assert!(!looks_like_file_path("hello"));
802 assert!(!looks_like_file_path("a.b"));
803 assert!(!looks_like_file_path(".hidden"));
804 }
805
806 #[test]
807 fn extract_file_refs_from_text() {
808 let text = "Changed `src/main.rs` and core/utils.ts for the fix";
809 let refs = extract_file_refs(text);
810 assert!(refs.contains(&"src/main.rs".to_string()));
811 assert!(refs.contains(&"core/utils.ts".to_string()));
812 }
813
814 #[test]
815 fn enrichment_stats_merge() {
816 let mut a = EnrichmentStats {
817 commits_indexed: 5,
818 tests_indexed: 3,
819 knowledge_indexed: 2,
820 edges_created: 10,
821 ..Default::default()
822 };
823 let b = EnrichmentStats {
824 commits_indexed: 2,
825 tests_indexed: 1,
826 knowledge_indexed: 0,
827 edges_created: 4,
828 ..Default::default()
829 };
830 a.merge(&b);
831 assert_eq!(a.commits_indexed, 7);
832 assert_eq!(a.edges_created, 14);
833 }
834
835 #[test]
836 fn enrichment_stats_format() {
837 let s = EnrichmentStats {
838 commits_indexed: 10,
839 tests_indexed: 5,
840 knowledge_indexed: 3,
841 edges_created: 20,
842 ..Default::default()
843 };
844 let fmt = s.format_summary();
845 assert!(fmt.contains("10 commits"));
846 assert!(fmt.contains("5 tests"));
847 }
848
849 #[test]
850 fn commit_node_construction() {
851 let node = Node::commit("abc1234", "feat: add feature");
852 assert_eq!(node.kind, NodeKind::Commit);
853 assert_eq!(node.name, "abc1234");
854 }
855
856 #[test]
857 fn test_node_construction() {
858 let node = Node::test("src/utils_test.rs", "src/utils_test.rs");
859 assert_eq!(node.kind, NodeKind::Test);
860 assert_eq!(node.file_path, "src/utils_test.rs");
861 }
862
863 #[test]
864 fn knowledge_node_construction() {
865 let node = Node::knowledge("k1", "Database uses PostgreSQL");
866 assert_eq!(node.kind, NodeKind::Knowledge);
867 assert!(node.metadata.unwrap().contains("PostgreSQL"));
868 }
869
870 #[test]
871 fn graph_commit_and_edge() {
872 let g = CodeGraph::open_in_memory().unwrap();
873 let file_id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
874 let commit_id = g.upsert_node(&Node::commit("abc1234", "fix bug")).unwrap();
875 g.upsert_edge(&Edge::new(file_id, commit_id, EdgeKind::ChangedIn))
876 .unwrap();
877
878 let edges = g.edges_from(file_id).unwrap();
879 assert_eq!(edges.len(), 1);
880 assert_eq!(edges[0].kind, EdgeKind::ChangedIn);
881 }
882
883 #[test]
884 fn graph_test_edge() {
885 let g = CodeGraph::open_in_memory().unwrap();
886 let code_id = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
887 let test_id = g
888 .upsert_node(&Node::test("src/utils_test.rs", "test_parse"))
889 .unwrap();
890 g.upsert_edge(&Edge::new(code_id, test_id, EdgeKind::TestedBy))
891 .unwrap();
892
893 let edges = g.edges_from(code_id).unwrap();
894 assert_eq!(edges[0].kind, EdgeKind::TestedBy);
895 }
896
897 #[test]
898 fn graph_knowledge_edge() {
899 let g = CodeGraph::open_in_memory().unwrap();
900 let file_id = g.upsert_node(&Node::file("src/db.rs")).unwrap();
901 let k_id = g
902 .upsert_node(&Node::knowledge("db_type", "Uses PostgreSQL"))
903 .unwrap();
904 g.upsert_edge(&Edge::new(file_id, k_id, EdgeKind::MentionedIn))
905 .unwrap();
906
907 let edges = g.edges_from(file_id).unwrap();
908 assert_eq!(edges[0].kind, EdgeKind::MentionedIn);
909 }
910
911 #[test]
914 fn consolidate_calls_resolves_in_caller_scope_and_skips_ambiguous() {
915 use crate::core::call_graph::{CallEdge, CallGraphInputs, SymbolSpan};
916 let sym = |file: &str| SymbolSpan {
917 file: file.into(),
918 name: "save".into(),
919 start_line: 1,
920 end_line: 3,
921 ..Default::default()
922 };
923 let call = |file: &str| CallEdge {
924 caller_file: file.into(),
925 caller_symbol: "f".into(),
926 caller_line: 1,
927 callee_name: "save".into(),
928 ..Default::default()
929 };
930 let inputs = CallGraphInputs {
931 project_root: "/p".into(),
932 symbols: vec![sym("b.rs"), sym("a.rs")],
933 import_edges: vec![("c.rs".into(), "b.rs".into())],
934 ..Default::default()
935 };
936
937 let g = CodeGraph::open_in_memory().unwrap();
938 let ids: Vec<i64> = ["a.rs", "b.rs", "c.rs", "d.rs"]
939 .iter()
940 .map(|f| g.upsert_node(&Node::file(f)).unwrap())
941 .collect();
942
943 use crate::core::semantic::{Escalation, SemanticVerdict};
945 let run = |verdicts: Vec<Option<SemanticVerdict>>, unsettled: Vec<bool>| Escalation {
946 verdicts,
947 unsettled,
948 ..Default::default()
949 };
950 let edges = [call("c.rs"), call("d.rs")];
951 let structural = crate::core::call_graph::resolve_edge_callee_targets(&inputs, &edges);
952 consolidate_call_edges(
953 &g,
954 &edges,
955 &structural,
956 &run(vec![None, None], vec![false; 2]),
957 )
958 .unwrap();
959
960 let from_c = g.edges_from(ids[2]).unwrap();
961 assert_eq!(from_c.len(), 1);
962 assert_eq!(from_c[0].target_id, ids[1]);
963 assert_eq!(
964 EdgeEvidence::from_metadata(from_c[0].metadata.as_deref()).map(|e| e.grade),
965 Some(EvidenceGrade::ResolvedStructural)
966 );
967 assert!(g.edges_from(ids[3]).unwrap().is_empty());
968
969 let verdicts = vec![
973 Some(SemanticVerdict::NotInProject),
974 Some(SemanticVerdict::Verified {
975 file: "a.rs".into(),
976 backend: "lsp:ra@1".into(),
977 }),
978 ];
979 consolidate_call_edges(&g, &edges, &structural, &run(verdicts, vec![false; 2])).unwrap();
980
981 assert!(
982 g.edges_from(ids[2]).unwrap().is_empty(),
983 "vetoed edge pruned"
984 );
985 let from_d = g.edges_from(ids[3]).unwrap();
986 assert_eq!(from_d.len(), 1);
987 assert_eq!(from_d[0].target_id, ids[0]);
988 let evidence = EdgeEvidence::from_metadata(from_d[0].metadata.as_deref()).unwrap();
989 assert_eq!(evidence.grade, EvidenceGrade::VerifiedSemantic);
990 assert_eq!(
991 evidence.primary().and_then(|c| c.backend.as_deref()),
992 Some("lsp:ra@1")
993 );
994
995 consolidate_call_edges(
998 &g,
999 &edges,
1000 &structural,
1001 &run(vec![None, None], vec![false, true]),
1002 )
1003 .unwrap();
1004 assert_eq!(
1005 g.edges_from(ids[3]).unwrap().len(),
1006 1,
1007 "unsettled caller kept"
1008 );
1009 }
1010
1011 #[test]
1014 fn a_fact_that_is_not_served_is_not_indexed_into_the_graph() {
1015 use crate::core::context_admission::stored::lineage_support::{
1016 governed, project, scoped, two_facts, withdraw_source,
1017 };
1018 let _data = governed();
1019 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1020 let (_directory, root) = project();
1021 two_facts(&root, "architecture");
1022 let indexed = || {
1023 let graph = CodeGraph::open_in_memory().expect("graph");
1024 scoped(&root, || index_knowledge(&graph, &root))
1025 .expect("index")
1026 .knowledge_indexed
1027 };
1028 assert_eq!(indexed(), 2);
1029 withdraw_source(&root);
1030 assert_eq!(indexed(), 1);
1031 }
1032}