Skip to main content

lean_ctx/core/
graph_enricher.rs

1//! Unified Graph Enricher — indexes Git history, tests, and knowledge into the PropertyGraph.
2//!
3//! Three enrichment passes:
4//! 1. **Git commits**: `git log` → Commit nodes + `changed_in` edges
5//! 2. **Test files**: naming/annotation heuristics → Test nodes + `tested_by` edges
6//! 3. **Knowledge bridge**: `ctx_knowledge` facts → Knowledge nodes + `mentioned_in` edges
7
8use 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// ---------------------------------------------------------------------------
14// Git History Indexer
15// ---------------------------------------------------------------------------
16
17#[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
126// ---------------------------------------------------------------------------
127// Test Indexer
128// ---------------------------------------------------------------------------
129
130const 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
226// ---------------------------------------------------------------------------
227// Knowledge Bridge
228// ---------------------------------------------------------------------------
229
230pub(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    // A graph node keeps the fact's value: only facts that may be delivered
244    // now are indexed (`ProjectKnowledge::delivery_filter`). The node keeps
245    // no source binding, so what is later read from the graph has no lineage.
246    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// ---------------------------------------------------------------------------
310// Full enrichment pipeline
311// ---------------------------------------------------------------------------
312
313#[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    /// Semantic escalation of structurally uncertain call edges.
320    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
378/// Background semantic pass after a graph build: refreshes evidence-graded
379/// call and `implements` edges — but only when it can add something.
380/// `off` never runs; `auto` runs only while a semantic backend is already
381/// live for the project (warm server or open IDE), so an idle machine pays
382/// nothing; `eager` always runs. All live work is budget-bounded.
383pub(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
404/// How long no tool call may have run before a backend-triggered pass starts.
405const IDLE_BEFORE_REFRESH: std::time::Duration = std::time::Duration::from_secs(5);
406
407/// Tool calls in flight in this process, and when the last one ended — so
408/// background semantic passes run in the agent's pauses, not alongside its
409/// calls. Lock-free: two atomics.
410pub struct ToolActivity;
411
412static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
413/// Milliseconds since `ACTIVITY_EPOCH` at the last call's end.
414static 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
418/// Ends one tool call on drop (also on panic or early return).
419pub 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    /// No call in flight, and none ended within `quiet`.
428    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
446/// Minimum spacing of backend-triggered refreshes per project: a burst of
447/// `ctx_refactor` calls warming several languages yields one pass.
448const BACKEND_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
449/// Retry delay when a pass could not run (graph not ready, backend kept busy).
450const BACKEND_REFRESH_RETRY: std::time::Duration = std::time::Duration::from_secs(30);
451
452/// A semantic backend is in use *in this process* (a language server started
453/// for `ctx_refactor`, an attached IDE). Graph builds may run in another
454/// process (the daemon) that cannot see this backend, so `auto` mode would
455/// otherwise never use it: schedule a bounded refresh here, where the backend
456/// lives. It waits until the current call is done (never competing with
457/// interactive use) and runs only on a current, populated property graph.
458/// At most one pass per project every [`BACKEND_REFRESH_INTERVAL`]; a pass
459/// that could not run is retried on a use after [`BACKEND_REFRESH_RETRY`].
460pub(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    /// Earliest next pass per project root.
465    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    // Unit tests seed fake backends for fake roots; never refresh those.
474    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        // Expired entries carry no state; the map stays bounded by the
485        // projects with a pass due or running.
486        next.retain(|_, t| now < *t);
487        // Reserved while the pass is pending.
488        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                // Never compete with the agent: the pass reads every indexed
499                // file and keeps the server busy for up to a minute.
500                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    // Cache maintenance belongs here, off every interactive path.
542    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    // A class's supertypes include the interfaces it implements: keep one
576    // edge per file pair, the more specific `implements`.
577    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
596/// Per file pair: strongest evidence grade, the backend behind it (smallest
597/// identity on ties, for determinism) and the number of supporting sites.
598type PairEvidence = (EvidenceGrade, Option<String>, u32);
599
600/// Lifts call edges to file-level `Calls` edges with typed evidence.
601///
602/// Per edge, a semantic verdict wins over structure: `Verified` binds the
603/// callee file; `NotInProject` vetoes a structural name match. Otherwise the
604/// callee is resolved in the caller's own scope (same file → unique import →
605/// unique project-wide); an ambiguous name yields no edge rather than an
606/// arbitrary one. Pairs are visited in sorted order (deterministic graph).
607///
608/// This producer then withdraws its contribution from edges it no longer
609/// derives — but only for callers whose candidates were all settled this
610/// pass: an unanswered site (backend unavailable or busy, budget, cold
611/// server) is not evidence that an earlier verified edge is wrong.
612fn 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
679/// Writes a relation pass's edges (except those `skip` rejects) and
680/// withdraws the ones this producer no longer derives — for settled
681/// declaring files only.
682pub 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
716/// Withdraws enrichment's contribution from file→file edges of `kind` that
717/// `keep` no longer confirms. An edge disappears only when no other producer
718/// still derives it; edges without typed evidence are never touched.
719fn 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// ---------------------------------------------------------------------------
735// Tests
736// ---------------------------------------------------------------------------
737
738#[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    /// Regression: a name→file map (last symbol wins) attributed every `save()`
912    /// call to whichever `save` was listed last, inventing false `Calls` edges.
913    #[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        // c.rs imports b.rs → b.rs; d.rs has no scope hint → ambiguous → no edge.
944        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        // Semantic verdicts: the backend binds d.rs's ambiguous call to a.rs
970        // and places c.rs's callee outside the project — the structural c→b
971        // edge this producer wrote before must disappear.
972        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        // Regression (review): d.rs changed and the backend is cold — its
996        // site is unanswered. That is not evidence against d→a: it stays.
997        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    /// Only facts that are served now become graph nodes: the fact bound to
1012    /// a deleted file is not indexed into a new graph.
1013    #[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}