Skip to main content

lean_ctx/tools/
ctx_impact.rs

1//! `ctx_impact` — Graph-based impact analysis tool.
2//!
3//! Uses the SQLite-backed Property Graph to answer: "What breaks when file X changes?"
4//! Performs BFS traversal of reverse import edges to find all transitively affected files.
5
6use crate::core::property_graph::{CodeGraph, DependencyChain, Edge, EdgeKind, ImpactResult, Node};
7use crate::core::tokens::count_tokens;
8use crate::core::type_ref_edges::{DefIndex, ExtMethodIndex};
9use serde_json::{Value, json};
10use std::collections::BTreeSet;
11use std::path::Path;
12use std::process::Stdio;
13
14/// Extensions whose files become Property Graph source nodes. Must stay a subset
15/// of `language_capabilities::is_indexable_ext` and align with the deep-query
16/// extractors (`deep_queries::{type_defs, calls}`) so each language contributes
17/// real symbol/import/call structure rather than bare file nodes.
18const GRAPH_SOURCE_EXTS: &[&str] = &[
19    "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "gd", "cs",
20];
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23enum OutputFormat {
24    Text,
25    Json,
26}
27
28fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
29    let f = format.unwrap_or("text").trim().to_lowercase();
30    match f.as_str() {
31        "text" => Ok(OutputFormat::Text),
32        "json" => Ok(OutputFormat::Json),
33        _ => Err("Error: format must be text|json".to_string()),
34    }
35}
36
37pub fn handle(
38    action: &str,
39    path: Option<&str>,
40    root: &str,
41    depth: Option<usize>,
42    format: Option<&str>,
43) -> String {
44    let fmt = match parse_format(format) {
45        Ok(f) => f,
46        Err(e) => return e,
47    };
48
49    match action {
50        "analyze" => handle_analyze(path, root, depth.unwrap_or(5), fmt),
51        "diff" => handle_diff(root, depth.unwrap_or(5), fmt),
52        "chain" => handle_chain(path, root, fmt),
53        "build" => handle_build(root, fmt),
54        "update" => handle_update(root, fmt),
55        "status" => handle_status(root, fmt),
56        "parity" => handle_parity(root, fmt),
57        _ => "Unknown action. Use: analyze, diff, chain, build, status, update, parity".to_string(),
58    }
59}
60
61/// Shadow-mode parity proof (#682.3): build an in-memory PropertyGraph from the
62/// current graph_index and quantify whether PG reproduces everything the
63/// facade exposes (symbols, edges, dependencies) before any backend flip.
64fn handle_parity(root: &str, fmt: OutputFormat) -> String {
65    // Compare the *fresh extractor* output (a real graph_index scan, built
66    // in-memory from the file walk + signature extraction) against a
67    // PropertyGraph populated from it — the genuine "mirror is lossless"
68    // invariant. Loading the persisted index would be circular since #696 C4
69    // (it is itself materialized from the PG), yielding a meaningless trivially
70    // lossless result, so always rescan to keep this a real proof.
71    let index = crate::core::graph_index::scan_with_content_cache(root).0;
72
73    let report = match crate::core::graph_parity::compare(&index) {
74        Ok(r) => r,
75        Err(e) => return format!("Parity comparison failed: {e}"),
76    };
77
78    match fmt {
79        OutputFormat::Json => {
80            let v = json!({
81                "tool": "ctx_impact",
82                "action": "parity",
83                "lossless": report.is_lossless(),
84                "files": report.files,
85                "symbols": { "gi": report.symbol_count_gi, "pg": report.symbol_count_pg,
86                             "matched": report.symbols_matched, "checked": report.symbols_checked },
87                "edges": { "gi": report.edge_count_gi, "pg": report.edge_count_pg,
88                           "superset": report.edge_pairs_lossless },
89                "dependencies": { "lossless": report.dependencies_lossless,
90                                  "checked": report.files_checked, "extra": report.dependencies_extra },
91                "dependents": { "lossless": report.dependents_lossless, "checked": report.files_checked },
92                "divergences": report.divergences,
93            });
94            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
95        }
96        OutputFormat::Text => {
97            let body = crate::core::graph_parity::format_report(&report);
98            let tokens = count_tokens(&body);
99            format!("{body}\n[ctx_impact parity: {tokens} tok]")
100        }
101    }
102}
103
104fn open_graph(root: &str) -> Result<CodeGraph, String> {
105    CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
106}
107
108/// Open the property graph for a *query*, rebuilding first when it cannot be
109/// trusted: either empty (never built) or produced by an engine older than
110/// [`crate::core::property_graph::GRAPH_ENGINE_VERSION`] — e.g. an upgraded
111/// install whose graph predates the C#/Java `type_ref` edges (GH #398). The
112/// rebuild is one-shot and idempotent: a fresh build stamps the current engine
113/// version, so a healthy graph is returned without rebuilding.
114fn open_graph_fresh(root: &str) -> Result<CodeGraph, String> {
115    let graph = open_graph(root)?;
116    let empty = graph.node_count().unwrap_or(0) == 0;
117    let outdated = !empty && crate::core::property_graph::engine_outdated(root);
118    if empty || outdated {
119        drop(graph);
120        let build_result = handle_build(root, OutputFormat::Text);
121        tracing::info!(
122            "Rebuilt property graph before impact query ({}): {}",
123            if empty { "empty" } else { "engine outdated" },
124            &build_result[..build_result.len().min(100)]
125        );
126        return open_graph(root);
127    }
128    Ok(graph)
129}
130
131fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize, fmt: OutputFormat) -> String {
132    let Some(target) = path else {
133        return "path is required for 'analyze' action".to_string();
134    };
135
136    let graph = match open_graph_fresh(root) {
137        Ok(g) => g,
138        Err(e) => return e,
139    };
140
141    if graph.node_count().unwrap_or(0) == 0 {
142        return "Graph is empty after auto-build. No supported source files found.".to_string();
143    }
144
145    let rel_target = graph_target_key(target, root);
146
147    // 1) Direct file-node match — the documented contract (a file path).
148    if graph.get_node_by_path(&rel_target).ok().flatten().is_some() {
149        let impact = match graph.impact_analysis(&rel_target, max_depth) {
150            Ok(r) => r,
151            Err(e) => return format!("Impact analysis failed: {e}"),
152        };
153        return format_impact(&impact, &rel_target, root, fmt);
154    }
155
156    // 2) Symbol-name fallback (GH #398): callers — and LLMs — routinely ask for
157    //    the impact of a *class/type* by name (`ctx_impact analyze ArcPoint`)
158    //    rather than its file path. Resolve the bare name to the file(s) that
159    //    define it and report their combined blast radius, instead of the
160    //    misleading "leaf node" answer a non-file target produced before.
161    let symbol = symbol_query_name(target);
162    if !symbol.is_empty()
163        && let Ok(def_files) = graph.resolve_symbol_def_files(&symbol)
164        && !def_files.is_empty()
165    {
166        return analyze_symbol(&graph, &symbol, &def_files, root, max_depth, fmt);
167    }
168
169    // 3) Neither a file nor a known symbol: an actionable diagnostic beats a
170    //    false "no impact".
171    analyze_unresolved(&graph, target, &rel_target, root, fmt)
172}
173
174/// Reduce a user-supplied target to a bare symbol name for the #398 fallback:
175/// drop any directory prefix and a single trailing source extension, so
176/// `Models/ArcPoint.cs`, `ArcPoint.cs` and `ArcPoint` all query `ArcPoint`.
177/// Returns an empty string for inputs that cannot name a single symbol
178/// (namespace separators, generics, globs, whitespace) — those would only
179/// produce bogus matches.
180fn symbol_query_name(target: &str) -> String {
181    let base = target.rsplit(['/', '\\']).next().unwrap_or(target).trim();
182    let stem = base
183        .rsplit_once('.')
184        .filter(|(_, ext)| GRAPH_SOURCE_EXTS.contains(ext))
185        .map_or(base, |(s, _)| s);
186    if stem.is_empty()
187        || stem.contains(|c: char| {
188            c.is_whitespace() || matches!(c, '.' | ':' | '*' | '<' | '>' | '(' | ')' | '/' | '\\')
189        })
190    {
191        return String::new();
192    }
193    stem.to_string()
194}
195
196/// Combined blast radius of every file that defines `symbol` (GH #398
197/// symbol-name fallback). The defining files are what changes, so they are
198/// excluded from the affected set; the resolved files are surfaced so the
199/// answer stays transparent. Output is sorted + capped for determinism (#498).
200fn analyze_symbol(
201    graph: &CodeGraph,
202    symbol: &str,
203    def_files: &[String],
204    root: &str,
205    max_depth: usize,
206    fmt: OutputFormat,
207) -> String {
208    let mut affected: BTreeSet<String> = BTreeSet::new();
209    let mut max_depth_reached = 0usize;
210    let mut edges_traversed = 0usize;
211    for f in def_files {
212        if let Ok(r) = graph.impact_analysis(f, max_depth) {
213            max_depth_reached = max_depth_reached.max(r.max_depth_reached);
214            edges_traversed += r.edges_traversed;
215            affected.extend(r.affected_files);
216        }
217    }
218    // The definers are the thing being changed, not impacted by it.
219    for f in def_files {
220        affected.remove(f);
221    }
222
223    let mut sorted: Vec<String> = affected.into_iter().collect();
224    let total = sorted.len();
225    let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
226    let truncated = total > limit;
227    if truncated {
228        sorted.truncate(limit);
229    }
230
231    match fmt {
232        OutputFormat::Json => {
233            let v = json!({
234                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
235                "tool": "ctx_impact",
236                "action": "analyze",
237                "project": project_meta(root),
238                "graph": graph_summary(root),
239                "graph_meta": crate::core::property_graph::load_meta(root),
240                "target": symbol,
241                "resolved_from": "symbol",
242                "defined_in": def_files,
243                "max_depth_reached": max_depth_reached,
244                "edges_traversed": edges_traversed,
245                "affected_files_total": total,
246                "affected_files": sorted,
247                "truncated": truncated
248            });
249            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
250        }
251        OutputFormat::Text => {
252            let defined = def_files.join(", ");
253            if total == 0 {
254                let result = format!(
255                    "No files depend on {symbol} (defined in {defined}); it is a leaf in the dependency graph."
256                );
257                let tokens = count_tokens(&result);
258                return format!("{result}\n[ctx_impact: {tokens} tok]");
259            }
260            let mut result = format!(
261                "Impact of changing {symbol} (defined in {defined}): {total} affected files \
262                 (depth: {max_depth_reached}, edges traversed: {edges_traversed})\n"
263            );
264            for file in &sorted {
265                result.push_str(&format!("  {file}\n"));
266            }
267            if truncated {
268                result.push_str(&format!("  ... +{} more\n", total - limit));
269            }
270            let tokens = count_tokens(&result);
271            format!("{result}[ctx_impact: {tokens} tok]")
272        }
273    }
274}
275
276/// Diagnostic for an `analyze` target that matched neither a file node nor a
277/// symbol. Replaces the old silent "leaf node" answer — indistinguishable from
278/// a real leaf — with the indexed counts and a concrete next step (GH #398).
279fn analyze_unresolved(
280    graph: &CodeGraph,
281    target: &str,
282    rel_target: &str,
283    root: &str,
284    fmt: OutputFormat,
285) -> String {
286    let files = graph.file_node_count().unwrap_or(0);
287    let symbols = graph.symbol_count().unwrap_or(0);
288    match fmt {
289        OutputFormat::Json => {
290            let v = json!({
291                "tool": "ctx_impact",
292                "action": "analyze",
293                "project": project_meta(root),
294                "graph": graph_summary(root),
295                "target": target,
296                "resolved": false,
297                "indexed_files": files,
298                "indexed_symbols": symbols,
299                "hint": "Target is neither an indexed file path nor a known symbol. Pass a path relative to the project root, or rebuild with action='build'."
300            });
301            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
302        }
303        OutputFormat::Text => {
304            let result = format!(
305                "'{target}' is not a known file or symbol in the graph \
306                 ({files} files, {symbols} symbols indexed).\n  \
307                 - As a file: pass a path relative to the project root (looked up '{rel_target}').\n  \
308                 - As a class/type: check the spelling, or run ctx_impact action='build' to (re)index."
309            );
310            let tokens = count_tokens(&result);
311            format!("{result}\n[ctx_impact: {tokens} tok]")
312        }
313    }
314}
315
316fn format_impact(impact: &ImpactResult, target: &str, root: &str, fmt: OutputFormat) -> String {
317    let mut sorted = impact.affected_files.clone();
318    sorted.sort();
319
320    let total = sorted.len();
321    let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
322    let truncated = total > limit;
323    if truncated {
324        sorted.truncate(limit);
325    }
326
327    match fmt {
328        OutputFormat::Json => {
329            let v = json!({
330                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
331                "tool": "ctx_impact",
332                "action": "analyze",
333                "project": project_meta(root),
334                "graph": graph_summary(root),
335                "graph_meta": crate::core::property_graph::load_meta(root),
336                "target": target,
337                "max_depth_reached": impact.max_depth_reached,
338                "edges_traversed": impact.edges_traversed,
339                "affected_files_total": total,
340                "affected_files": sorted,
341                "truncated": truncated
342            });
343            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
344        }
345        OutputFormat::Text => {
346            if total == 0 {
347                let result =
348                    format!("No files depend on {target} (leaf node in the dependency graph).");
349                let tokens = count_tokens(&result);
350                return format!("{result}\n[ctx_impact: {tokens} tok]");
351            }
352
353            let mut result = format!(
354                "Impact of changing {target}: {total} affected files (depth: {}, edges traversed: {})\n",
355                impact.max_depth_reached, impact.edges_traversed
356            );
357
358            for file in &sorted {
359                result.push_str(&format!("  {file}\n"));
360            }
361            if truncated {
362                result.push_str(&format!("  ... +{} more\n", total - limit));
363            }
364
365            let tokens = count_tokens(&result);
366            format!("{result}[ctx_impact: {tokens} tok]")
367        }
368    }
369}
370
371fn handle_diff(root: &str, max_depth: usize, fmt: OutputFormat) -> String {
372    let changed = git_changed_files(root);
373    if changed.is_empty() {
374        return match fmt {
375            OutputFormat::Json => {
376                let v = json!({
377                    "tool": "ctx_impact",
378                    "action": "diff",
379                    "changed_files": [],
380                    "blast_radius": [],
381                    "total_affected": 0
382                });
383                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
384            }
385            OutputFormat::Text => "No uncommitted changes found.".to_string(),
386        };
387    }
388
389    let graph = match open_graph_fresh(root) {
390        Ok(g) => g,
391        Err(e) => return e,
392    };
393
394    compute_diff_impact(&graph, &changed, root, max_depth, fmt)
395}
396
397fn git_changed_files(root: &str) -> Vec<String> {
398    let output = std::process::Command::new("git")
399        .args(["diff", "--name-only", "HEAD"])
400        .current_dir(root)
401        .stdout(Stdio::piped())
402        .stderr(Stdio::null())
403        .output();
404
405    let mut files: BTreeSet<String> = BTreeSet::new();
406
407    if let Ok(o) = output
408        && o.status.success()
409    {
410        for line in String::from_utf8_lossy(&o.stdout).lines() {
411            let trimmed = line.trim();
412            if !trimmed.is_empty() {
413                files.insert(trimmed.to_string());
414            }
415        }
416    }
417
418    let staged = std::process::Command::new("git")
419        .args(["diff", "--name-only", "--cached"])
420        .current_dir(root)
421        .stdout(Stdio::piped())
422        .stderr(Stdio::null())
423        .output();
424
425    if let Ok(o) = staged
426        && o.status.success()
427    {
428        for line in String::from_utf8_lossy(&o.stdout).lines() {
429            let trimmed = line.trim();
430            if !trimmed.is_empty() {
431                files.insert(trimmed.to_string());
432            }
433        }
434    }
435
436    let untracked = std::process::Command::new("git")
437        .args(["ls-files", "--others", "--exclude-standard"])
438        .current_dir(root)
439        .stdout(Stdio::piped())
440        .stderr(Stdio::null())
441        .output();
442
443    if let Ok(o) = untracked
444        && o.status.success()
445    {
446        for line in String::from_utf8_lossy(&o.stdout).lines() {
447            let trimmed = line.trim();
448            if !trimmed.is_empty() {
449                files.insert(trimmed.to_string());
450            }
451        }
452    }
453
454    files.into_iter().collect()
455}
456
457fn compute_diff_impact(
458    graph: &CodeGraph,
459    changed: &[String],
460    root: &str,
461    max_depth: usize,
462    fmt: OutputFormat,
463) -> String {
464    let mut all_affected: BTreeSet<String> = BTreeSet::new();
465    let mut per_file: Vec<(String, Vec<String>)> = Vec::new();
466
467    for file in changed {
468        let rel = graph_target_key(file, root);
469        if let Ok(impact) = graph.impact_analysis(&rel, max_depth) {
470            let mut affected: Vec<String> = impact
471                .affected_files
472                .into_iter()
473                .filter(|f| !changed.contains(f))
474                .collect();
475            affected.sort();
476            for a in &affected {
477                all_affected.insert(a.clone());
478            }
479            if !affected.is_empty() {
480                per_file.push((rel, affected));
481            }
482        }
483    }
484
485    match fmt {
486        OutputFormat::Json => {
487            let items: Vec<Value> = per_file
488                .iter()
489                .map(|(file, affected)| {
490                    json!({
491                        "changed_file": file,
492                        "affected": affected,
493                        "count": affected.len()
494                    })
495                })
496                .collect();
497            let v = json!({
498                "tool": "ctx_impact",
499                "action": "diff",
500                "changed_files": changed,
501                "blast_radius": items,
502                "total_affected": all_affected.len()
503            });
504            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
505        }
506        OutputFormat::Text => {
507            let mut result = format!(
508                "Diff Impact Analysis ({} changed files, {} blast radius)\n\n",
509                changed.len(),
510                all_affected.len()
511            );
512            result.push_str("Changed files:\n");
513            for f in changed.iter().take(30) {
514                result.push_str(&format!("  {f}\n"));
515            }
516
517            if !per_file.is_empty() {
518                result.push_str("\nBlast radius:\n");
519                for (file, affected) in per_file.iter().take(15) {
520                    result.push_str(&format!("  {file} -> {} affected\n", affected.len()));
521                    for a in affected.iter().take(10) {
522                        result.push_str(&format!("    {a}\n"));
523                    }
524                    if affected.len() > 10 {
525                        result.push_str(&format!("    ... +{} more\n", affected.len() - 10));
526                    }
527                }
528            }
529
530            let tokens = count_tokens(&result);
531            format!("{result}\n[ctx_impact diff: {tokens} tok]")
532        }
533    }
534}
535
536fn handle_chain(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
537    let Some(spec) = path else {
538        return "path is required for 'chain' action (format: from_file->to_file)".to_string();
539    };
540
541    let (from, to) = match spec.split_once("->") {
542        Some((f, t)) => (f.trim(), t.trim()),
543        None => {
544            return format!(
545                "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
546                 Example: src/server.rs->src/core/config.rs"
547            );
548        }
549    };
550
551    let graph = match open_graph_fresh(root) {
552        Ok(g) => g,
553        Err(e) => return e,
554    };
555
556    let rel_from = graph_target_key(from, root);
557    let rel_to = graph_target_key(to, root);
558
559    match graph.dependency_chain(&rel_from, &rel_to) {
560        Ok(Some(chain)) => format_chain(&chain, root, fmt),
561        Ok(None) => match fmt {
562            OutputFormat::Json => {
563                let v = json!({
564                    "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
565                    "tool": "ctx_impact",
566                    "action": "chain",
567                    "project": project_meta(root),
568                    "graph": graph_summary(root),
569                    "graph_meta": crate::core::property_graph::load_meta(root),
570                    "from": rel_from,
571                    "to": rel_to,
572                    "found": false
573                });
574                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
575            }
576            OutputFormat::Text => {
577                let result = format!("No dependency path from {rel_from} to {rel_to}");
578                let tokens = count_tokens(&result);
579                format!("{result}\n[ctx_impact chain: {tokens} tok]")
580            }
581        },
582        Err(e) => format!("Chain analysis failed: {e}"),
583    }
584}
585
586fn format_chain(chain: &DependencyChain, root: &str, fmt: OutputFormat) -> String {
587    match fmt {
588        OutputFormat::Json => {
589            let v = json!({
590                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
591                "tool": "ctx_impact",
592                "action": "chain",
593                "project": project_meta(root),
594                "graph": graph_summary(root),
595                "graph_meta": crate::core::property_graph::load_meta(root),
596                "found": true,
597                "depth": chain.depth,
598                "path": chain.path
599            });
600            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
601        }
602        OutputFormat::Text => {
603            let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
604            for (i, step) in chain.path.iter().enumerate() {
605                if i > 0 {
606                    result.push_str("  -> ");
607                } else {
608                    result.push_str("  ");
609                }
610                result.push_str(step);
611                result.push('\n');
612            }
613            let tokens = count_tokens(&result);
614            format!("{result}[ctx_impact chain: {tokens} tok]")
615        }
616    }
617}
618
619fn graph_target_key(path: &str, root: &str) -> String {
620    let rel = crate::core::index_paths::graph_relative_key(path, root);
621    let rel_key = crate::core::index_paths::graph_match_key(&rel);
622    if rel_key.is_empty() {
623        crate::core::index_paths::graph_match_key(path)
624    } else {
625        rel_key
626    }
627}
628
629fn walk_supported_sources(root_path: &Path) -> (Vec<String>, Vec<(String, String, String)>) {
630    let walker = ignore::WalkBuilder::new(root_path)
631        .hidden(true)
632        .git_ignore(true)
633        .require_git(false)
634        .filter_entry(crate::core::walk_filter::keep_entry)
635        .build();
636
637    let mut file_paths: Vec<String> = Vec::new();
638    let mut file_contents: Vec<(String, String, String)> = Vec::new();
639
640    for entry in walker.flatten() {
641        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
642            continue;
643        }
644
645        let path = entry.path();
646        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
647
648        if !GRAPH_SOURCE_EXTS.contains(&ext) {
649            continue;
650        }
651
652        // Canonical `/` separators: graph node keys must be platform-stable
653        // so queries like `impact_analysis("Models/Engine.cs")` match the
654        // same node on Windows (output determinism, #498).
655        let rel_path = path
656            .strip_prefix(root_path)
657            .unwrap_or(path)
658            .to_string_lossy()
659            .replace('\\', "/");
660
661        file_paths.push(rel_path.clone());
662
663        if let Ok(content) = std::fs::read_to_string(path) {
664            file_contents.push((rel_path, content, ext.to_string()));
665        }
666    }
667
668    file_paths.sort();
669    file_paths.dedup();
670    file_contents.sort_by(|a, b| a.0.cmp(&b.0));
671    (file_paths, file_contents)
672}
673
674/// Per-file analysis borrowed from the walked contents: (path, content, ext, analysis).
675type AnalyzedFile<'a> = (
676    &'a str,
677    &'a str,
678    &'a str,
679    crate::core::deep_queries::DeepAnalysis,
680);
681
682/// Analyze every walked source file once (parallel) and build the global
683/// symbol-definition index and the extension-method index. Shared by full
684/// build and incremental update on both builder paths.
685fn analyze_all(
686    file_contents: &[(String, String, String)],
687) -> (Vec<AnalyzedFile<'_>>, DefIndex, ExtMethodIndex) {
688    use rayon::prelude::*;
689    let per_file: Vec<AnalyzedFile<'_>> = file_contents
690        .par_iter()
691        .map(|(p, c, e)| {
692            (
693                p.as_str(),
694                c.as_str(),
695                e.as_str(),
696                crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
697            )
698        })
699        .collect();
700
701    // Single source of truth for the #398 indexes (shared with the graph_index
702    // mirror so both builders resolve identical type-usage edges).
703    let def_index =
704        crate::core::type_ref_edges::build_def_index(per_file.iter().map(|(p, _, _, a)| (*p, a)));
705    let ext_method_index = crate::core::type_ref_edges::build_ext_method_index(
706        per_file.iter().map(|(p, _, _, a)| (*p, a)),
707    );
708
709    (per_file, def_index, ext_method_index)
710}
711
712/// Insert `TypeRef` edges for every resolved type usage:
713/// - file -> defining file (drives `impact_analysis` blast radius; the
714///   `graph_index` mirror produces the identical file edge via
715///   [`crate::core::type_ref_edges::cross_file_type_edges`] so a reindex cannot
716///   drop it — GH #398),
717/// - file -> defined type symbol (clears the symbol from `dead_code`, whose
718///   query already exempts `type_ref` targets; symbol-level edges live only on
719///   this builder path).
720fn insert_type_ref_edges(
721    graph: &CodeGraph,
722    file_node_id: i64,
723    rel_path: &str,
724    type_uses: &[crate::core::deep_queries::TypeUse],
725    def_index: &DefIndex,
726    visible_ns: &std::collections::HashSet<String>,
727) -> usize {
728    let mut added = 0usize;
729    for (target_file, type_name, line_start, line_end) in
730        crate::core::type_ref_edges::type_ref_targets(def_index, type_uses, rel_path, visible_ns)
731    {
732        let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
733            continue;
734        };
735        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
736        added += 1;
737
738        let sym_node = Node::symbol(
739            &type_name,
740            &target_file,
741            crate::core::property_graph::NodeKind::Symbol,
742        )
743        .with_lines(line_start, line_end);
744        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
745            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
746            added += 1;
747        }
748    }
749    added
750}
751
752/// Insert `TypeRef` edges for resolved C# extension-method calls: a
753/// `value.Foo()` call links the consuming file to the file that defines the
754/// `this`-parameter method `Foo`. Mirrors `insert_type_ref_edges` (file +
755/// symbol edge). Resolution is by method name alone, so the same self-filter
756/// and a failsafe cap keep it bounded; the index only ever holds genuine
757/// extension methods, which keeps the name space small and distinct.
758fn insert_ext_method_edges(
759    graph: &CodeGraph,
760    file_node_id: i64,
761    rel_path: &str,
762    calls: &[crate::core::deep_queries::CallSite],
763    ext_method_index: &ExtMethodIndex,
764) -> usize {
765    let mut added = 0usize;
766    for (target_file, method_name, line_start, line_end) in
767        crate::core::type_ref_edges::ext_method_targets(ext_method_index, calls, rel_path)
768    {
769        let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
770            continue;
771        };
772        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
773        added += 1;
774
775        let sym_node = Node::symbol(
776            &method_name,
777            &target_file,
778            crate::core::property_graph::NodeKind::Symbol,
779        )
780        .with_lines(line_start, line_end);
781        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
782            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
783            added += 1;
784        }
785    }
786    added
787}
788
789fn normalize_git_path(line: &str) -> String {
790    line.trim().replace('\\', "/")
791}
792
793fn git_diff_name_only_lines(project_root: &Path, args: &[&str]) -> Option<Vec<String>> {
794    let out = std::process::Command::new("git")
795        .args(args)
796        .current_dir(project_root)
797        .stdout(Stdio::piped())
798        .stderr(Stdio::null())
799        .output()
800        .ok()?;
801    if !out.status.success() {
802        return None;
803    }
804    let s = String::from_utf8(out.stdout).ok()?;
805    Some(
806        s.lines()
807            .map(normalize_git_path)
808            .filter(|l| !l.is_empty())
809            .collect(),
810    )
811}
812
813fn collect_git_changed_paths(project_root: &Path, last_git_head: &str) -> Option<BTreeSet<String>> {
814    let range = format!("{last_git_head}..HEAD");
815    let mut set: BTreeSet<String> = BTreeSet::new();
816    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", &range])? {
817        set.insert(line);
818    }
819    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only"])? {
820        set.insert(line);
821    }
822    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", "--cached"])? {
823        set.insert(line);
824    }
825    Some(set)
826}
827
828#[cfg(feature = "embeddings")]
829fn enclosing_symbol_name_for_line(
830    types: &[crate::core::deep_queries::TypeDef],
831    line: usize,
832) -> String {
833    let mut best: Option<(&crate::core::deep_queries::TypeDef, usize)> = None;
834    for t in types {
835        if line >= t.line && line <= t.end_line {
836            let span = t.end_line.saturating_sub(t.line);
837            match best {
838                None => best = Some((t, span)),
839                Some((_, prev_span)) => {
840                    if span < prev_span {
841                        best = Some((t, span));
842                    }
843                }
844            }
845        }
846    }
847    best.map_or_else(|| "<module>".to_string(), |(t, _)| t.name.clone())
848}
849
850#[cfg(feature = "embeddings")]
851fn resolve_call_callee_site(
852    def_index: &DefIndex,
853    callee: &str,
854    caller_file: &str,
855) -> Option<(String, usize, usize)> {
856    let sites = def_index.get(callee)?;
857    for (f, _ns, ls, le) in sites {
858        if f == caller_file {
859            return Some((f.clone(), *ls, *le));
860        }
861    }
862    let mut sorted: Vec<(String, usize, usize)> = sites
863        .iter()
864        .map(|(f, _ns, ls, le)| (f.clone(), *ls, *le))
865        .collect();
866    sorted.sort_by(|a, b| a.0.cmp(&b.0));
867    sorted.into_iter().next()
868}
869
870#[cfg(feature = "embeddings")]
871fn index_graph_file_embeddings(
872    graph: &CodeGraph,
873    rel_path: &str,
874    ext: &str,
875    analysis: &crate::core::deep_queries::DeepAnalysis,
876    resolver_ctx: &crate::core::import_resolver::ResolverContext,
877    def_index: &DefIndex,
878    ext_method_index: &ExtMethodIndex,
879) -> (usize, usize) {
880    let mut total_nodes = 0usize;
881    let mut total_edges = 0usize;
882
883    let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
884        return (0, 0);
885    };
886    total_nodes += 1;
887
888    for type_def in &analysis.types {
889        let sym_node = Node::symbol(
890            &type_def.name,
891            rel_path,
892            crate::core::property_graph::NodeKind::Symbol,
893        )
894        .with_lines(type_def.line, type_def.end_line);
895        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
896            total_nodes += 1;
897            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
898            total_edges += 1;
899            if type_def.is_exported {
900                let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
901                total_edges += 1;
902            }
903        }
904    }
905
906    let resolved = crate::core::import_resolver::resolve_imports(
907        &analysis.imports,
908        rel_path,
909        ext,
910        resolver_ctx,
911    );
912
913    let mut targets: Vec<String> = resolved
914        .into_iter()
915        .filter(|imp| !imp.is_external)
916        .filter_map(|imp| imp.resolved_path)
917        .collect();
918    targets.sort();
919    targets.dedup();
920
921    for target_path in targets {
922        let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
923            continue;
924        };
925        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
926        total_edges += 1;
927    }
928
929    for call in &analysis.calls {
930        let caller_name = enclosing_symbol_name_for_line(&analysis.types, call.line);
931        let mut caller_node = Node::symbol(
932            &caller_name,
933            rel_path,
934            crate::core::property_graph::NodeKind::Symbol,
935        );
936        if let Some(t) = analysis.types.iter().find(|t| t.name == caller_name) {
937            caller_node = caller_node.with_lines(t.line, t.end_line);
938        }
939        let Ok(caller_id) = graph.upsert_node(&caller_node) else {
940            continue;
941        };
942        total_nodes += 1;
943
944        let Some((callee_file, c_line, c_end)) =
945            resolve_call_callee_site(def_index, &call.callee, rel_path)
946        else {
947            continue;
948        };
949
950        let callee_node = Node::symbol(
951            &call.callee,
952            &callee_file,
953            crate::core::property_graph::NodeKind::Symbol,
954        )
955        .with_lines(c_line, c_end);
956        let Ok(callee_id) = graph.upsert_node(&callee_node) else {
957            continue;
958        };
959        total_nodes += 1;
960        let _ = graph.upsert_edge(&Edge::new(caller_id, callee_id, EdgeKind::Calls));
961        total_edges += 1;
962
963        if callee_file != rel_path {
964            let Ok(callee_file_id) = graph.upsert_node(&Node::file(&callee_file)) else {
965                continue;
966            };
967            let _ = graph.upsert_edge(&Edge::new(file_node_id, callee_file_id, EdgeKind::Calls));
968            total_edges += 1;
969        }
970    }
971
972    // Type-usage edges close the same-namespace gap (C#/Java, GH #398):
973    // a file consuming a project type without importing it still depends on
974    // the defining file. Resolution is namespace-aware for C#.
975    let visible_ns = if ext == "cs" {
976        crate::core::type_ref_edges::csharp_visible_namespaces(analysis)
977    } else {
978        std::collections::HashSet::new()
979    };
980    total_edges += insert_type_ref_edges(
981        graph,
982        file_node_id,
983        rel_path,
984        &analysis.type_uses,
985        def_index,
986        &visible_ns,
987    );
988    // Extension-method calls (`value.Foo()`) depend on the defining file too.
989    total_edges += insert_ext_method_edges(
990        graph,
991        file_node_id,
992        rel_path,
993        &analysis.calls,
994        ext_method_index,
995    );
996
997    (total_nodes, total_edges)
998}
999
1000#[cfg(not(feature = "embeddings"))]
1001fn index_graph_file_minimal(
1002    graph: &CodeGraph,
1003    rel_path: &str,
1004    content: &str,
1005    ext: &str,
1006    analysis: &crate::core::deep_queries::DeepAnalysis,
1007    resolver_ctx: &crate::core::import_resolver::ResolverContext,
1008    def_index: &DefIndex,
1009    ext_method_index: &ExtMethodIndex,
1010) -> (usize, usize) {
1011    let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
1012        return (0, 0);
1013    };
1014    let mut total_nodes = 1usize;
1015    let mut total_edges = 0usize;
1016
1017    let resolved = crate::core::import_resolver::resolve_imports(
1018        &analysis.imports,
1019        rel_path,
1020        ext,
1021        resolver_ctx,
1022    );
1023
1024    let mut targets: Vec<String> = resolved
1025        .into_iter()
1026        .filter(|imp| !imp.is_external)
1027        .filter_map(|imp| imp.resolved_path)
1028        .filter(|p| p != rel_path)
1029        .collect();
1030    targets.sort();
1031    targets.dedup();
1032
1033    for target_path in targets {
1034        let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
1035            continue;
1036        };
1037        total_nodes += 1;
1038        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
1039        total_edges += 1;
1040    }
1041
1042    for type_def in &analysis.types {
1043        if type_def.is_exported {
1044            let sym_node = Node::symbol(
1045                &type_def.name,
1046                rel_path,
1047                crate::core::property_graph::NodeKind::Symbol,
1048            )
1049            .with_lines(type_def.line, type_def.end_line);
1050            if let Ok(sym_id) = graph.upsert_node(&sym_node) {
1051                total_nodes += 1;
1052                let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
1053                let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
1054                total_edges += 2;
1055            }
1056        }
1057    }
1058
1059    // Same-namespace type consumption (C#/Java, GH #398) — see the
1060    // embeddings-path counterpart in `index_graph_file_embeddings`.
1061    let visible_ns = if ext == "cs" {
1062        crate::core::type_ref_edges::csharp_visible_namespaces(analysis)
1063    } else {
1064        std::collections::HashSet::new()
1065    };
1066    total_edges += insert_type_ref_edges(
1067        graph,
1068        file_node_id,
1069        rel_path,
1070        &analysis.type_uses,
1071        def_index,
1072        &visible_ns,
1073    );
1074    total_edges += insert_ext_method_edges(
1075        graph,
1076        file_node_id,
1077        rel_path,
1078        &analysis.calls,
1079        ext_method_index,
1080    );
1081
1082    let exports: Vec<String> = analysis
1083        .types
1084        .iter()
1085        .filter(|t| t.is_exported)
1086        .map(|t| t.name.clone())
1087        .collect();
1088    let line_count = content.lines().count();
1089    let token_count = crate::core::tokens::count_tokens(content);
1090    let hash = {
1091        use md5::{Digest, Md5};
1092        let mut h = Md5::new();
1093        h.update(content.as_bytes());
1094        crate::core::agent_identity::hex_encode(&h.finalize())
1095    };
1096    let _ = graph.upsert_file_catalog(&crate::core::property_graph::FileCatalogEntry {
1097        path: rel_path.to_string(),
1098        hash,
1099        language: ext.to_string(),
1100        line_count,
1101        token_count,
1102        exports,
1103        summary: String::new(),
1104    });
1105
1106    (total_nodes, total_edges)
1107}
1108
1109fn handle_build(root: &str, fmt: OutputFormat) -> String {
1110    let t0 = std::time::Instant::now();
1111    let root_path = Path::new(root);
1112
1113    let graph = match open_graph(root) {
1114        Ok(g) => g,
1115        Err(e) => return e,
1116    };
1117
1118    let incremental_hint: Option<&'static str> = {
1119        let nodes_ok = graph.node_count().unwrap_or(0) > 0;
1120        let has_head = crate::core::property_graph::load_meta(root)
1121            .and_then(|m| m.git_head)
1122            .is_some_and(|s| !s.is_empty());
1123        if nodes_ok && has_head {
1124            Some(
1125                "Hint: Graph already indexed — for faster refresh, use ctx_impact action='update' \
1126                 to apply incremental git-based updates instead of a full rebuild.",
1127            )
1128        } else {
1129            None
1130        }
1131    };
1132
1133    if let Err(e) = graph.clear() {
1134        return format!("Failed to clear graph: {e}");
1135    }
1136
1137    let (file_paths, file_contents) = walk_supported_sources(root_path);
1138
1139    let cs_contents: std::collections::HashMap<String, String> = file_contents
1140        .iter()
1141        .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1142        .map(|(p, c, _)| (p.clone(), c.clone()))
1143        .collect();
1144    let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1145        root_path,
1146        file_paths.clone(),
1147        &cs_contents,
1148    );
1149
1150    let mut total_nodes = 0usize;
1151    let mut total_edges = 0usize;
1152
1153    let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1154
1155    #[cfg(feature = "embeddings")]
1156    for (rel_path, _content, ext, analysis) in &per_file {
1157        let (n, e) = index_graph_file_embeddings(
1158            &graph,
1159            rel_path,
1160            ext,
1161            analysis,
1162            &resolver_ctx,
1163            &def_index,
1164            &ext_method_index,
1165        );
1166        total_nodes += n;
1167        total_edges += e;
1168    }
1169
1170    #[cfg(not(feature = "embeddings"))]
1171    for (rel_path, content, ext, analysis) in &per_file {
1172        let (n, e) = index_graph_file_minimal(
1173            &graph,
1174            rel_path,
1175            content,
1176            ext,
1177            analysis,
1178            &resolver_ctx,
1179            &def_index,
1180            &ext_method_index,
1181        );
1182        total_nodes += n;
1183        total_edges += e;
1184    }
1185
1186    let build_time_ms = t0.elapsed().as_millis() as u64;
1187
1188    let db_display = graph.db_path().display();
1189    let mut result = format!(
1190        "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
1191         Stored at: {db_display}\n\
1192         Build time: {build_time_ms}ms",
1193        file_contents.len(),
1194    );
1195    if let Some(h) = incremental_hint {
1196        result.push('\n');
1197        result.push_str(h);
1198    }
1199
1200    let _ = crate::core::property_graph::write_meta(
1201        root,
1202        &crate::core::property_graph::PropertyGraphMetaV1 {
1203            schema_version: 1,
1204            engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1205            built_with: env!("CARGO_PKG_VERSION").to_string(),
1206            project_root: crate::core::graph_index::normalize_project_root(root),
1207            built_at: chrono::Utc::now().to_rfc3339(),
1208            git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1209            git_dirty: Some(git_dirty(root_path)),
1210            nodes: graph.node_count().ok(),
1211            edges: graph.edge_count().ok(),
1212            files_indexed: Some(file_contents.len()),
1213            build_time_ms: Some(build_time_ms),
1214        },
1215    );
1216
1217    let tokens = count_tokens(&result);
1218    match fmt {
1219        OutputFormat::Json => {
1220            let mut v = serde_json::json!({
1221                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1222                "tool": "ctx_impact",
1223                "action": "build",
1224                "project": project_meta(root),
1225                "graph": graph_summary(root),
1226                "graph_meta": crate::core::property_graph::load_meta(root),
1227                "indexed_files": file_contents.len(),
1228                "nodes": total_nodes,
1229                "edges": total_edges,
1230                "build_time_ms": build_time_ms,
1231                "db_path": graph.db_path().display().to_string()
1232            });
1233            if let Some(h) = incremental_hint {
1234                v.as_object_mut()
1235                    .map(|m| m.insert("incremental_hint".to_string(), json!(h)));
1236            }
1237            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1238        }
1239        OutputFormat::Text => format!("{result}\n[ctx_impact build: {tokens} tok]"),
1240    }
1241}
1242
1243fn handle_update(root: &str, fmt: OutputFormat) -> String {
1244    let t0 = std::time::Instant::now();
1245    let root_path = Path::new(root);
1246
1247    let graph = match open_graph(root) {
1248        Ok(g) => g,
1249        Err(e) => return e,
1250    };
1251
1252    if graph.node_count().unwrap_or(0) == 0 {
1253        return handle_build(root, fmt);
1254    }
1255
1256    let Some(meta) = crate::core::property_graph::load_meta(root) else {
1257        return handle_build(root, fmt);
1258    };
1259
1260    let Some(last_git_head) = meta.git_head.filter(|s| !s.is_empty()) else {
1261        return handle_build(root, fmt);
1262    };
1263
1264    let Some(changed) = collect_git_changed_paths(root_path, &last_git_head) else {
1265        return handle_build(root, fmt);
1266    };
1267
1268    let changed_count = changed.len();
1269    let (file_paths, file_contents) = walk_supported_sources(root_path);
1270    let cs_contents: std::collections::HashMap<String, String> = file_contents
1271        .iter()
1272        .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1273        .map(|(p, c, _)| (p.clone(), c.clone()))
1274        .collect();
1275    let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1276        root_path,
1277        file_paths.clone(),
1278        &cs_contents,
1279    );
1280
1281    let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1282
1283    let mut total_nodes = 0usize;
1284    let mut total_edges = 0usize;
1285
1286    for rel_path in &changed {
1287        let p = Path::new(rel_path);
1288        let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
1289        let supported = GRAPH_SOURCE_EXTS.contains(&ext);
1290        let abs = root_path.join(rel_path);
1291
1292        if !abs.exists() {
1293            if supported {
1294                let _ = graph.remove_file_nodes(rel_path);
1295            }
1296            continue;
1297        }
1298
1299        if !supported {
1300            continue;
1301        }
1302
1303        if let Err(e) = graph.remove_file_nodes(rel_path) {
1304            return format!("Failed to remove old nodes for {rel_path}: {e}");
1305        }
1306
1307        let Some((_, _content, ext_owned, analysis)) =
1308            per_file.iter().find(|(p, _, _, _)| *p == rel_path)
1309        else {
1310            continue;
1311        };
1312
1313        #[cfg(feature = "embeddings")]
1314        {
1315            let (n, e) = index_graph_file_embeddings(
1316                &graph,
1317                rel_path,
1318                ext_owned,
1319                analysis,
1320                &resolver_ctx,
1321                &def_index,
1322                &ext_method_index,
1323            );
1324            total_nodes += n;
1325            total_edges += e;
1326        }
1327
1328        #[cfg(not(feature = "embeddings"))]
1329        {
1330            let (n, e) = index_graph_file_minimal(
1331                &graph,
1332                rel_path,
1333                _content,
1334                ext_owned,
1335                analysis,
1336                &resolver_ctx,
1337                &def_index,
1338                &ext_method_index,
1339            );
1340            total_nodes += n;
1341            total_edges += e;
1342        }
1343    }
1344
1345    let elapsed_ms = t0.elapsed().as_millis() as u64;
1346
1347    let _ = crate::core::property_graph::write_meta(
1348        root,
1349        &crate::core::property_graph::PropertyGraphMetaV1 {
1350            schema_version: 1,
1351            engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1352            built_with: env!("CARGO_PKG_VERSION").to_string(),
1353            project_root: crate::core::graph_index::normalize_project_root(root),
1354            built_at: chrono::Utc::now().to_rfc3339(),
1355            git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1356            git_dirty: Some(git_dirty(root_path)),
1357            nodes: graph.node_count().ok(),
1358            edges: graph.edge_count().ok(),
1359            files_indexed: Some(file_contents.len()),
1360            build_time_ms: Some(elapsed_ms),
1361        },
1362    );
1363
1364    let summary = format!(
1365        "Incremental update: {changed_count} files changed, {total_nodes} nodes updated, {total_edges} edges added ({elapsed_ms}ms)"
1366    );
1367
1368    let tokens = count_tokens(&summary);
1369    match fmt {
1370        OutputFormat::Json => {
1371            let v = json!({
1372                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1373                "tool": "ctx_impact",
1374                "action": "update",
1375                "project": project_meta(root),
1376                "graph": graph_summary(root),
1377                "graph_meta": crate::core::property_graph::load_meta(root),
1378                "git_range_from": last_git_head,
1379                "files_changed_reported": changed_count,
1380                "nodes_added": total_nodes,
1381                "edges_added": total_edges,
1382                "update_time_ms": elapsed_ms,
1383                "db_path": graph.db_path().display().to_string()
1384            });
1385            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1386        }
1387        OutputFormat::Text => format!("{summary}\n[ctx_impact update: {tokens} tok]"),
1388    }
1389}
1390
1391fn handle_status(root: &str, fmt: OutputFormat) -> String {
1392    let graph = match open_graph(root) {
1393        Ok(g) => g,
1394        Err(e) => return e,
1395    };
1396
1397    let nodes = graph.node_count().unwrap_or(0);
1398    let edges = graph.edge_count().unwrap_or(0);
1399
1400    if nodes == 0 {
1401        return match fmt {
1402            OutputFormat::Json => {
1403                let v = json!({
1404                    "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1405                    "tool": "ctx_impact",
1406                    "action": "status",
1407                    "project": project_meta(root),
1408                    "graph": graph_summary(root),
1409                    "freshness": "empty",
1410                    "hint": "Run ctx_impact action='build' to index."
1411                });
1412                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1413            }
1414            OutputFormat::Text => {
1415                "Graph is empty. Run ctx_impact action='build' to index.".to_string()
1416            }
1417        };
1418    }
1419
1420    let root_path = Path::new(root);
1421    let meta = crate::core::property_graph::load_meta(root);
1422    let current_head = git_out(root_path, &["rev-parse", "--short", "HEAD"]);
1423    let current_dirty = git_dirty(root_path);
1424    let stale = meta.as_ref().is_some_and(|m| {
1425        let head_mismatch = match (m.git_head.as_ref(), current_head.as_ref()) {
1426            (Some(a), Some(b)) => a != b,
1427            _ => false,
1428        };
1429        let dirty_mismatch = match (m.git_dirty, Some(current_dirty)) {
1430            (Some(a), Some(b)) => a != b,
1431            _ => false,
1432        };
1433        head_mismatch || dirty_mismatch
1434    });
1435    let freshness = if stale { "stale" } else { "fresh" };
1436
1437    match fmt {
1438        OutputFormat::Json => {
1439            let v = json!({
1440                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1441                "tool": "ctx_impact",
1442                "action": "status",
1443                "project": project_meta(root),
1444                "graph": graph_summary(root),
1445                "freshness": freshness,
1446                "meta": meta
1447            });
1448            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1449        }
1450        OutputFormat::Text => {
1451            let db_display = graph.db_path().display();
1452            let mut out =
1453                format!("Property Graph: {nodes} nodes, {edges} edges\nStored: {db_display}");
1454            if stale {
1455                out.push_str("\nWARNING: graph looks stale (git HEAD / dirty mismatch). Run ctx_impact action='build' to refresh.");
1456            }
1457            out
1458        }
1459    }
1460}
1461
1462fn project_meta(root: &str) -> Value {
1463    let root_hash = crate::core::project_hash::hash_project_root(root);
1464    let identity_hash = crate::core::project_hash::project_identity(root)
1465        .as_deref()
1466        .map(crate::core::hasher::hash_str);
1467
1468    let root_path = Path::new(root);
1469    json!({
1470        "project_root_hash": root_hash,
1471        "project_identity_hash": identity_hash,
1472        "git": {
1473            "head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1474            "branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
1475            "dirty": git_dirty(root_path)
1476        }
1477    })
1478}
1479
1480fn graph_summary(project_root: &str) -> Value {
1481    let graph_dir = crate::core::property_graph::graph_dir(project_root);
1482    let db_path = graph_dir.join("graph.db");
1483    let db_path_display = db_path.display().to_string();
1484    if !db_path.exists() {
1485        return json!({
1486            "exists": false,
1487            "db_path": db_path_display,
1488            "nodes": null,
1489            "edges": null
1490        });
1491    }
1492    match crate::core::property_graph::CodeGraph::open(project_root) {
1493        Ok(g) => json!({
1494            "exists": true,
1495            "db_path": g.db_path().display().to_string(),
1496            "nodes": g.node_count().ok(),
1497            "edges": g.edge_count().ok()
1498        }),
1499        Err(_) => json!({
1500            "exists": true,
1501            "db_path": db_path_display,
1502            "nodes": null,
1503            "edges": null
1504        }),
1505    }
1506}
1507
1508fn git_dirty(project_root: &Path) -> bool {
1509    let out = std::process::Command::new("git")
1510        .args(["status", "--porcelain"])
1511        .current_dir(project_root)
1512        .stdout(std::process::Stdio::piped())
1513        .stderr(std::process::Stdio::null())
1514        .output();
1515    match out {
1516        Ok(o) if o.status.success() => !o.stdout.is_empty(),
1517        _ => false,
1518    }
1519}
1520
1521fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
1522    let out = std::process::Command::new("git")
1523        .args(args)
1524        .current_dir(project_root)
1525        .stdout(std::process::Stdio::piped())
1526        .stderr(std::process::Stdio::null())
1527        .output()
1528        .ok()?;
1529    if !out.status.success() {
1530        return None;
1531    }
1532    let s = String::from_utf8(out.stdout).ok()?;
1533    let s = s.trim().to_string();
1534    if s.is_empty() { None } else { Some(s) }
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539    use super::*;
1540
1541    #[test]
1542    fn format_impact_empty() {
1543        let impact = ImpactResult {
1544            root_file: "a.rs".to_string(),
1545            affected_files: vec![],
1546            max_depth_reached: 0,
1547            edges_traversed: 0,
1548        };
1549        let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1550        assert!(result.contains("No files depend on"));
1551    }
1552
1553    #[test]
1554    fn format_impact_with_files() {
1555        let impact = ImpactResult {
1556            root_file: "a.rs".to_string(),
1557            affected_files: vec!["b.rs".to_string(), "c.rs".to_string()],
1558            max_depth_reached: 2,
1559            edges_traversed: 3,
1560        };
1561        let result = format_impact(&impact, "a.rs", "/tmp", OutputFormat::Text);
1562        assert!(result.contains("2 affected files"));
1563        assert!(result.contains("b.rs"));
1564        assert!(result.contains("c.rs"));
1565    }
1566
1567    #[test]
1568    fn format_chain_display() {
1569        let chain = DependencyChain {
1570            path: vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
1571            depth: 2,
1572        };
1573        let result = format_chain(&chain, "/tmp", OutputFormat::Text);
1574        assert!(result.contains("depth 2"));
1575        assert!(result.contains("a.rs"));
1576        assert!(result.contains("-> b.rs"));
1577        assert!(result.contains("-> c.rs"));
1578    }
1579
1580    #[test]
1581    fn handle_missing_path() {
1582        let result = handle("analyze", None, "/tmp", None, None);
1583        assert!(result.contains("path is required"));
1584    }
1585
1586    #[test]
1587    fn handle_invalid_chain_spec() {
1588        let result = handle("chain", Some("no_arrow_here"), "/tmp", None, None);
1589        assert!(result.contains("Invalid chain spec"));
1590    }
1591
1592    #[test]
1593    fn handle_unknown_action() {
1594        let result = handle("invalid", None, "/tmp", None, None);
1595        assert!(result.contains("Unknown action"));
1596    }
1597
1598    #[test]
1599    fn graph_target_key_normalizes_windows_styles() {
1600        let target = graph_target_key(r"C:/repo/src/main.rs", r"C:\repo");
1601        let expected = if cfg!(windows) {
1602            "src/main.rs"
1603        } else {
1604            "C:/repo/src/main.rs"
1605        };
1606        assert_eq!(target, expected);
1607    }
1608
1609    /// End-to-end regression for GH #365: build the property graph from real
1610    /// Python sources and assert that a class which is imported + instantiated
1611    /// cross-file is NOT reported as `dead_code`. This exercises the *builder*
1612    /// (symbol-level `Calls` edge for class instantiation), not just the SQL
1613    /// rule covered by the synthetic test in `core::smells`. The unused class
1614    /// must still be flagged so the test cannot pass vacuously.
1615    #[cfg(feature = "embeddings")]
1616    #[test]
1617    fn dead_code_builder_does_not_flag_instantiated_python_class() {
1618        // The property-graph DB path is derived from `LEAN_CTX_DATA_DIR`
1619        // (`graph_dir`), so a concurrent test that mutates that env var between
1620        // our `build` and `open` would point them at different directories and
1621        // yield an empty graph. Serialize on the shared lock that every other
1622        // data-dir-mutating test already uses.
1623        let _env = crate::core::data_dir::test_env_lock();
1624        let tmp = tempfile::tempdir().expect("tempdir");
1625        let root = tmp.path();
1626        std::fs::create_dir_all(root.join("models")).unwrap();
1627        std::fs::write(
1628            root.join("models/engine.py"),
1629            "class Engine:\n    def __init__(self, power):\n        self.power = power\n\n\n\
1630             class Pipeline:\n    def __init__(self, cfg):\n        self.cfg = cfg\n\n\n\
1631             class UnusedOrphan:\n    pass\n",
1632        )
1633        .unwrap();
1634        std::fs::write(
1635            root.join("app.py"),
1636            "from models.engine import Engine, Pipeline\n\n\
1637             engine = Engine(power=100)\npipeline = Pipeline(cfg={})\n",
1638        )
1639        .unwrap();
1640
1641        let root_str = root.to_string_lossy().to_string();
1642        let out = handle("build", None, &root_str, None, Some("text"));
1643        assert!(!out.contains("ERROR"), "graph build failed: {out}");
1644
1645        let graph =
1646            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1647        let findings = crate::core::smells::scan_rule(
1648            graph.connection(),
1649            "dead_code",
1650            &crate::core::smells::SmellConfig::default(),
1651        );
1652        let dead: Vec<String> = findings.iter().filter_map(|f| f.symbol.clone()).collect();
1653
1654        assert!(
1655            !dead.iter().any(|s| s == "Engine"),
1656            "instantiated class `Engine` must not be dead_code; findings: {dead:?}"
1657        );
1658        assert!(
1659            !dead.iter().any(|s| s == "Pipeline"),
1660            "instantiated class `Pipeline` must not be dead_code; findings: {dead:?}"
1661        );
1662        assert!(
1663            dead.iter().any(|s| s == "UnusedOrphan"),
1664            "never-referenced class `UnusedOrphan` should still be flagged (non-vacuous); \
1665             findings: {dead:?}"
1666        );
1667    }
1668
1669    /// End-to-end regression for GH #398: C# files in the same namespace use
1670    /// each other's types **without any `using` directive**, and dependency
1671    /// injection means the type is often never `new`-ed by its consumer. With
1672    /// import- and call-edges only, the consumed class is a false-negative
1673    /// leaf node. Type-usage edges (`TypeRef`) must connect consumer -> definer
1674    /// so impact analysis reports the real blast radius.
1675    #[cfg(feature = "embeddings")]
1676    #[test]
1677    fn csharp_same_namespace_type_use_is_not_a_leaf() {
1678        let _env = crate::core::data_dir::test_env_lock();
1679        let tmp = tempfile::tempdir().expect("tempdir");
1680        let root = tmp.path();
1681        std::fs::create_dir_all(root.join("Models")).unwrap();
1682        std::fs::create_dir_all(root.join("Services")).unwrap();
1683
1684        // The small class under change — no consumer imports it via `using`.
1685        std::fs::write(
1686            root.join("Models/Engine.cs"),
1687            "namespace App.Core;\n\n\
1688             public class Engine\n{\n    public int Power { get; set; }\n}\n",
1689        )
1690        .unwrap();
1691        // DI-style consumer: field + constructor parameter, never `new Engine()`.
1692        std::fs::write(
1693            root.join("Services/Motor.cs"),
1694            "namespace App.Core;\n\n\
1695             public class Motor\n{\n    private readonly Engine _engine;\n\n\
1696             \x20   public Motor(Engine engine)\n    {\n        _engine = engine;\n    }\n}\n",
1697        )
1698        .unwrap();
1699        // Inheritance consumer in a nested namespace part, also without `using`.
1700        std::fs::write(
1701            root.join("Services/TurboEngine.cs"),
1702            "namespace App.Core;\n\n\
1703             public class TurboEngine : Engine\n{\n    public int Boost { get; set; }\n}\n",
1704        )
1705        .unwrap();
1706        // Unrelated file: must NOT appear in the blast radius (non-vacuous).
1707        std::fs::write(
1708            root.join("Services/Logger.cs"),
1709            "namespace App.Core;\n\n\
1710             public class Logger\n{\n    public void Log(string msg) { }\n}\n",
1711        )
1712        .unwrap();
1713
1714        let root_str = root.to_string_lossy().to_string();
1715        let out = handle("build", None, &root_str, None, Some("text"));
1716        assert!(!out.contains("ERROR"), "graph build failed: {out}");
1717
1718        let graph =
1719            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1720        let impact = graph
1721            .impact_analysis("Models/Engine.cs", 5)
1722            .expect("impact analysis");
1723
1724        assert!(
1725            impact
1726                .affected_files
1727                .contains(&"Services/Motor.cs".to_string()),
1728            "DI consumer (field + ctor param, no using, no new) must be affected; got: {:?}",
1729            impact.affected_files
1730        );
1731        assert!(
1732            impact
1733                .affected_files
1734                .contains(&"Services/TurboEngine.cs".to_string()),
1735            "subclass (base_list, no using) must be affected; got: {:?}",
1736            impact.affected_files
1737        );
1738        assert!(
1739            !impact
1740                .affected_files
1741                .contains(&"Services/Logger.cs".to_string()),
1742            "unrelated file must NOT be affected; got: {:?}",
1743            impact.affected_files
1744        );
1745
1746        // Same root cause, second symptom: a class consumed only as a type
1747        // (DI) was flagged `dead_code` because nothing ever *called* it. The
1748        // symbol-level TypeRef edge must clear it; the genuinely unreferenced
1749        // Logger keeps the rule honest.
1750        let findings = crate::core::smells::scan_rule(
1751            graph.connection(),
1752            "dead_code",
1753            &crate::core::smells::SmellConfig::default(),
1754        );
1755        let dead: Vec<String> = findings.iter().filter_map(|f| f.symbol.clone()).collect();
1756        assert!(
1757            !dead.iter().any(|s| s == "Engine"),
1758            "type-consumed class `Engine` must not be dead_code; findings: {dead:?}"
1759        );
1760        assert!(
1761            dead.iter().any(|s| s == "Logger"),
1762            "never-referenced class `Logger` should still be flagged (non-vacuous); \
1763             findings: {dead:?}"
1764        );
1765    }
1766
1767    /// GH #398 end-to-end regression for the wipe every prior fix missed:
1768    /// `ctx_impact` builds precise `type_ref` edges, but a routine background
1769    /// reindex (`graph_index::scan` -> `ProjectIndex::save` -> `mirror_index`)
1770    /// clears the PropertyGraph and repopulates it from graph_index. Before the
1771    /// fix, graph_index emitted no type-usage edges, so the C# same-namespace
1772    /// consumer was silently dropped to a false-negative leaf right after the
1773    /// first dashboard/daemon reindex. The mirror must now preserve the blast
1774    /// radius. Gated on `tree-sitter`: needs the C# grammar, not embeddings.
1775    #[cfg(feature = "tree-sitter")]
1776    #[test]
1777    fn csharp_blast_radius_survives_background_reindex() {
1778        let _env = crate::core::data_dir::test_env_lock();
1779        let tmp = tempfile::tempdir().expect("tempdir");
1780        let root = tmp.path();
1781        // A project marker so graph_index accepts the root as safe to scan.
1782        std::fs::create_dir_all(root.join(".git")).unwrap();
1783        std::fs::create_dir_all(root.join("Models")).unwrap();
1784        std::fs::create_dir_all(root.join("Services")).unwrap();
1785
1786        std::fs::write(
1787            root.join("Models/Engine.cs"),
1788            "namespace App.Core;\n\n\
1789             public class Engine\n{\n    public int Power { get; set; }\n}\n",
1790        )
1791        .unwrap();
1792        // DI-style consumer: field + ctor param, no `using`, no `new Engine()`.
1793        std::fs::write(
1794            root.join("Services/Motor.cs"),
1795            "namespace App.Core;\n\n\
1796             public class Motor\n{\n    private readonly Engine _engine;\n\n\
1797             \x20   public Motor(Engine engine)\n    {\n        _engine = engine;\n    }\n}\n",
1798        )
1799        .unwrap();
1800
1801        let root_str = root.to_string_lossy().to_string();
1802
1803        // 1) ctx_impact's own builder writes the type_ref edges.
1804        let out = handle("build", None, &root_str, None, Some("text"));
1805        assert!(!out.contains("ERROR"), "graph build failed: {out}");
1806
1807        // 2) A background reindex mirrors graph_index over the PropertyGraph,
1808        //    clearing it first — exactly what the daemon / dashboard / ctx_graph
1809        //    trigger via ProjectIndex::save(). If the mirror dropped type_ref the
1810        //    consumer would vanish; an aborted (empty) scan would clear the graph
1811        //    entirely. Either failure mode makes the assertion below fail loudly.
1812        let _ = crate::core::graph_index::scan(&root_str);
1813
1814        // 3) The blast radius must survive the mirror (the actual GH #398 bug).
1815        let graph =
1816            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1817        let impact = graph
1818            .impact_analysis("Models/Engine.cs", 5)
1819            .expect("impact analysis");
1820        assert!(
1821            impact
1822                .affected_files
1823                .contains(&"Services/Motor.cs".to_string()),
1824            "same-namespace consumer must survive a background reindex; got: {:?}",
1825            impact.affected_files
1826        );
1827    }
1828
1829    /// Regression for GH #398's upgrade path: the v3.8.3 `type_ref` fix only
1830    /// helps if an *existing* graph is rebuilt after upgrading. A graph built by
1831    /// an older engine keeps `node_count > 0`, so without an engine-version gate
1832    /// `analyze` silently serves it — leaving the C# same-namespace consumer a
1833    /// false-negative leaf. We build a correct graph, stamp its meta back to
1834    /// engine version 0 (simulating a pre-`type_ref` build), and assert the next
1835    /// query self-heals: it rebuilds, surfaces the consumer, and re-stamps the
1836    /// current engine version.
1837    #[cfg(feature = "embeddings")]
1838    #[test]
1839    fn stale_engine_graph_is_rebuilt_before_query() {
1840        let _env = crate::core::data_dir::test_env_lock();
1841        let tmp = tempfile::tempdir().expect("tempdir");
1842        let root = tmp.path();
1843        std::fs::create_dir_all(root.join("Models")).unwrap();
1844        std::fs::create_dir_all(root.join("Services")).unwrap();
1845
1846        std::fs::write(
1847            root.join("Models/Engine.cs"),
1848            "namespace App.Core;\n\n\
1849             public class Engine\n{\n    public int Power { get; set; }\n}\n",
1850        )
1851        .unwrap();
1852        // DI-style consumer: field + constructor parameter, no `using`, no `new`.
1853        std::fs::write(
1854            root.join("Services/Motor.cs"),
1855            "namespace App.Core;\n\n\
1856             public class Motor\n{\n    private readonly Engine _engine;\n\n\
1857             \x20   public Motor(Engine engine)\n    {\n        _engine = engine;\n    }\n}\n",
1858        )
1859        .unwrap();
1860
1861        let root_str = root.to_string_lossy().to_string();
1862
1863        // Build a correct graph, then simulate a graph produced by an engine
1864        // that predates `type_ref` by stamping its meta back to version 0.
1865        let out = handle("build", None, &root_str, None, Some("text"));
1866        assert!(!out.contains("ERROR"), "graph build failed: {out}");
1867        let mut meta = crate::core::property_graph::load_meta(&root_str).expect("meta after build");
1868        assert_eq!(
1869            meta.engine_version,
1870            crate::core::property_graph::GRAPH_ENGINE_VERSION,
1871            "a fresh build must stamp the current engine version"
1872        );
1873        meta.engine_version = 0;
1874        crate::core::property_graph::write_meta(&root_str, &meta).expect("downgrade meta");
1875        assert!(
1876            crate::core::property_graph::engine_outdated(&root_str),
1877            "downgraded graph must read as outdated"
1878        );
1879
1880        // The query path must transparently rebuild the stale graph.
1881        let analysis = handle(
1882            "analyze",
1883            Some("Models/Engine.cs"),
1884            &root_str,
1885            None,
1886            Some("text"),
1887        );
1888        assert!(
1889            analysis.contains("Services/Motor.cs"),
1890            "stale graph must be rebuilt so the DI consumer surfaces; got: {analysis}"
1891        );
1892        let healed =
1893            crate::core::property_graph::load_meta(&root_str).expect("meta after self-heal");
1894        assert_eq!(
1895            healed.engine_version,
1896            crate::core::property_graph::GRAPH_ENGINE_VERSION,
1897            "self-heal must re-stamp the current engine version"
1898        );
1899    }
1900
1901    /// End-to-end regression for GH #398 (expression-position follow-up): a C#
1902    /// file that consumes types only through static calls, enum values or
1903    /// attributes — no `using`, no `new`, no field/param of that type — must
1904    /// still land in the blast radius of the defining file.
1905    ///
1906    /// Gated on `tree-sitter` (not `embeddings`) on purpose: the existing #398
1907    /// e2e tests only run under `embeddings`, leaving the
1908    /// `index_graph_file_minimal` builder path untested. This test exercises
1909    /// both builder paths (it runs whenever the C# grammar is available).
1910    #[cfg(feature = "tree-sitter")]
1911    #[test]
1912    fn csharp_expression_position_type_use_is_not_a_leaf() {
1913        let _env = crate::core::data_dir::test_env_lock();
1914        let tmp = tempfile::tempdir().expect("tempdir");
1915        let root = tmp.path();
1916        std::fs::create_dir_all(root.join("Models")).unwrap();
1917        std::fs::create_dir_all(root.join("Attributes")).unwrap();
1918        std::fs::create_dir_all(root.join("Services")).unwrap();
1919
1920        // Static factory + static field, used by a consumer via `Engine.X`.
1921        std::fs::write(
1922            root.join("Models/Engine.cs"),
1923            "namespace App.Core;\n\n\
1924             public class Engine\n{\n\
1925             \x20   public static Engine Create() => new Engine();\n\
1926             \x20   public static readonly int Default = 0;\n}\n",
1927        )
1928        .unwrap();
1929        // Enum, consumed only as `Status.Active`.
1930        std::fs::write(
1931            root.join("Models/Status.cs"),
1932            "namespace App.Core;\n\npublic enum Status { Active, Inactive }\n",
1933        )
1934        .unwrap();
1935        // Attribute class, consumed only as `[ApiController]`.
1936        std::fs::write(
1937            root.join("Attributes/ApiControllerAttribute.cs"),
1938            "using System;\n\nnamespace App.Core;\n\n\
1939             public class ApiControllerAttribute : Attribute { }\n",
1940        )
1941        .unwrap();
1942        // Consumer: ONLY expression-position usage — no using/new/field/param.
1943        std::fs::write(
1944            root.join("Services/Garage.cs"),
1945            "namespace App.Core;\n\n\
1946             [ApiController]\n\
1947             public class Garage\n{\n\
1948             \x20   public void Boot()\n    {\n\
1949             \x20       var e = Engine.Create();\n\
1950             \x20       var s = Status.Active;\n    }\n}\n",
1951        )
1952        .unwrap();
1953        // Unrelated control: must NOT appear in any blast radius (non-vacuous).
1954        std::fs::write(
1955            root.join("Services/Logger.cs"),
1956            "namespace App.Core;\n\n\
1957             public class Logger\n{\n    public void Log(string m) { }\n}\n",
1958        )
1959        .unwrap();
1960
1961        let root_str = root.to_string_lossy().to_string();
1962        let out = handle("build", None, &root_str, None, Some("text"));
1963        assert!(!out.contains("ERROR"), "graph build failed: {out}");
1964
1965        let graph =
1966            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
1967        let affected = |file: &str| -> Vec<String> {
1968            graph
1969                .impact_analysis(file, 5)
1970                .expect("impact analysis")
1971                .affected_files
1972        };
1973
1974        let engine_aff = affected("Models/Engine.cs");
1975        assert!(
1976            engine_aff.contains(&"Services/Garage.cs".to_string()),
1977            "static-call consumer (no using/new) must be affected by Engine.cs; got: {engine_aff:?}"
1978        );
1979        assert!(
1980            !engine_aff.contains(&"Services/Logger.cs".to_string()),
1981            "unrelated file must NOT be affected by Engine.cs; got: {engine_aff:?}"
1982        );
1983
1984        let status_aff = affected("Models/Status.cs");
1985        assert!(
1986            status_aff.contains(&"Services/Garage.cs".to_string()),
1987            "enum-value consumer must be affected by Status.cs; got: {status_aff:?}"
1988        );
1989
1990        let attr_aff = affected("Attributes/ApiControllerAttribute.cs");
1991        assert!(
1992            attr_aff.contains(&"Services/Garage.cs".to_string()),
1993            "attribute consumer must be affected by ApiControllerAttribute.cs; got: {attr_aff:?}"
1994        );
1995    }
1996
1997    /// GH #398 follow-up (extension methods, #642): an extension method
1998    /// `value.WordCount()` makes the consuming file depend on the file that
1999    /// *defines* the extension — the receiver is an instance and the definer's
2000    /// type name is never written, so neither import- nor type-usage edges
2001    /// connect them. Only a method-host edge does. Gated on `tree-sitter` so
2002    /// both builder paths (embeddings + minimal) are exercised.
2003    #[cfg(feature = "tree-sitter")]
2004    #[test]
2005    fn csharp_extension_method_host_is_in_blast_radius() {
2006        let _env = crate::core::data_dir::test_env_lock();
2007        let tmp = tempfile::tempdir().expect("tempdir");
2008        let root = tmp.path();
2009        std::fs::create_dir_all(root.join("Extensions")).unwrap();
2010        std::fs::create_dir_all(root.join("Services")).unwrap();
2011
2012        // Extension host: static class, first parameter carries `this`. Same
2013        // namespace as the consumer, so it is callable without a `using` —
2014        // which keeps the test free of any import edge.
2015        std::fs::write(
2016            root.join("Extensions/StringExtensions.cs"),
2017            "namespace App.Core;\n\n\
2018             public static class StringExtensions\n{\n\
2019             \x20   public static int WordCount(this string s) => s.Length;\n}\n",
2020        )
2021        .unwrap();
2022        // Consumer: calls the extension as if it were an instance method.
2023        std::fs::write(
2024            root.join("Services/Report.cs"),
2025            "namespace App.Core;\n\n\
2026             public class Report\n{\n\
2027             \x20   public int Count(string text) => text.WordCount();\n}\n",
2028        )
2029        .unwrap();
2030        // Unrelated control: must NOT be linked (non-vacuous).
2031        std::fs::write(
2032            root.join("Services/Logger.cs"),
2033            "namespace App.Core;\n\n\
2034             public class Logger\n{\n    public void Log(string m) { }\n}\n",
2035        )
2036        .unwrap();
2037
2038        let root_str = root.to_string_lossy().to_string();
2039        let out = handle("build", None, &root_str, None, Some("text"));
2040        assert!(!out.contains("ERROR"), "graph build failed: {out}");
2041
2042        let graph =
2043            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2044        let affected = graph
2045            .impact_analysis("Extensions/StringExtensions.cs", 5)
2046            .expect("impact analysis")
2047            .affected_files;
2048        assert!(
2049            affected.contains(&"Services/Report.cs".to_string()),
2050            "extension-method consumer must be in the host's blast radius; got: {affected:?}"
2051        );
2052        assert!(
2053            !affected.contains(&"Services/Logger.cs".to_string()),
2054            "unrelated file must NOT be affected; got: {affected:?}"
2055        );
2056    }
2057
2058    /// GH #398 follow-up (namespace-aware resolution, #641): two project types
2059    /// share a name in different namespaces. A consumer that sees only one of
2060    /// them (its own namespace) must link to *that* definer and not the
2061    /// homonym in an unrelated namespace. The pre-fix resolver linked both.
2062    #[cfg(feature = "tree-sitter")]
2063    #[test]
2064    fn csharp_type_use_namespace_disambiguation() {
2065        let _env = crate::core::data_dir::test_env_lock();
2066        let tmp = tempfile::tempdir().expect("tempdir");
2067        let root = tmp.path();
2068        std::fs::create_dir_all(root.join("Foo")).unwrap();
2069        std::fs::create_dir_all(root.join("Bar")).unwrap();
2070
2071        // Same type name, two namespaces.
2072        std::fs::write(
2073            root.join("Foo/Engine.cs"),
2074            "namespace App.Foo;\n\n\
2075             public class Engine\n{\n    public int Power { get; set; }\n}\n",
2076        )
2077        .unwrap();
2078        std::fs::write(
2079            root.join("Bar/Engine.cs"),
2080            "namespace App.Bar;\n\n\
2081             public class Engine\n{\n    public int Torque { get; set; }\n}\n",
2082        )
2083        .unwrap();
2084        // Consumer in App.Foo, no `using` — only `App.Foo.Engine` is visible.
2085        std::fs::write(
2086            root.join("Foo/Garage.cs"),
2087            "namespace App.Foo;\n\n\
2088             public class Garage\n{\n    private readonly Engine _engine;\n\n\
2089             \x20   public Garage(Engine engine)\n    {\n        _engine = engine;\n    }\n}\n",
2090        )
2091        .unwrap();
2092
2093        let root_str = root.to_string_lossy().to_string();
2094        let out = handle("build", None, &root_str, None, Some("text"));
2095        assert!(!out.contains("ERROR"), "graph build failed: {out}");
2096
2097        let graph =
2098            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2099        let affected = |file: &str| -> Vec<String> {
2100            graph
2101                .impact_analysis(file, 5)
2102                .expect("impact analysis")
2103                .affected_files
2104        };
2105
2106        assert!(
2107            affected("Foo/Engine.cs").contains(&"Foo/Garage.cs".to_string()),
2108            "consumer must depend on the same-namespace Engine; got: {:?}",
2109            affected("Foo/Engine.cs")
2110        );
2111        assert!(
2112            !affected("Bar/Engine.cs").contains(&"Foo/Garage.cs".to_string()),
2113            "consumer must NOT depend on the homonym in another namespace; got: {:?}",
2114            affected("Bar/Engine.cs")
2115        );
2116    }
2117
2118    /// GH #398 follow-up (cap bypass, #641): a type name defined in more files
2119    /// than the failsafe fallback cap is normally dropped as too generic. But
2120    /// when exactly one definer sits in the consumer's visible namespace, that
2121    /// unambiguous match must still be linked — the namespace evidence beats
2122    /// the global cap. The pre-fix resolver dropped the name entirely.
2123    #[cfg(feature = "tree-sitter")]
2124    #[test]
2125    fn csharp_type_use_namespace_cap_bypass() {
2126        let _env = crate::core::data_dir::test_env_lock();
2127        let tmp = tempfile::tempdir().expect("tempdir");
2128        let root = tmp.path();
2129
2130        // Six definers of `Widget` — past the fallback cap (5).
2131        let homonym_dirs = ["N1", "N2", "N3", "N4", "N5"];
2132        for d in homonym_dirs {
2133            std::fs::create_dir_all(root.join(d)).unwrap();
2134            std::fs::write(
2135                root.join(d).join("Widget.cs"),
2136                format!(
2137                    "namespace App.{d};\n\npublic class Widget\n{{\n    public int Id {{ get; set; }}\n}}\n"
2138                ),
2139            )
2140            .unwrap();
2141        }
2142        // The visible one, in the consumer's own namespace.
2143        std::fs::create_dir_all(root.join("Foo")).unwrap();
2144        std::fs::write(
2145            root.join("Foo/Widget.cs"),
2146            "namespace App.Foo;\n\npublic class Widget\n{\n    public int Tag { get; set; }\n}\n",
2147        )
2148        .unwrap();
2149        // Consumer in App.Foo, no `using`.
2150        std::fs::write(
2151            root.join("Foo/Dashboard.cs"),
2152            "namespace App.Foo;\n\n\
2153             public class Dashboard\n{\n    private readonly Widget _widget;\n\n\
2154             \x20   public Dashboard(Widget widget)\n    {\n        _widget = widget;\n    }\n}\n",
2155        )
2156        .unwrap();
2157
2158        let root_str = root.to_string_lossy().to_string();
2159        let out = handle("build", None, &root_str, None, Some("text"));
2160        assert!(!out.contains("ERROR"), "graph build failed: {out}");
2161
2162        let graph =
2163            crate::core::property_graph::CodeGraph::open(&root_str).expect("open property graph");
2164        let affected = |file: &str| -> Vec<String> {
2165            graph
2166                .impact_analysis(file, 5)
2167                .expect("impact analysis")
2168                .affected_files
2169        };
2170
2171        assert!(
2172            affected("Foo/Widget.cs").contains(&"Foo/Dashboard.cs".to_string()),
2173            "unambiguous same-namespace match must bypass the cap; got: {:?}",
2174            affected("Foo/Widget.cs")
2175        );
2176        // The homonyms in other namespaces stay unlinked.
2177        assert!(
2178            !affected("N1/Widget.cs").contains(&"Foo/Dashboard.cs".to_string()),
2179            "out-of-namespace homonym must NOT be linked; got: {:?}",
2180            affected("N1/Widget.cs")
2181        );
2182    }
2183
2184    /// GH #398 (real-world cross-namespace DI): every existing e2e test keeps
2185    /// consumer and definer in the *same* namespace. Real C# services live in
2186    /// their own namespace and reach a model/contract through a `using`
2187    /// directive, injected by interface (never `new`-ed). This exercises the
2188    /// full `handle("analyze")` text path — exactly what the MCP tool runs —
2189    /// for the interface contract changing:
2190    ///   * the concrete implementor (reached via the base list), and
2191    ///   * the cross-namespace DI consumer (reached via `using` + ctor param).
2192    #[cfg(feature = "tree-sitter")]
2193    #[test]
2194    fn csharp_cross_namespace_using_di_blast_radius() {
2195        let _env = crate::core::data_dir::test_env_lock();
2196        let tmp = tempfile::tempdir().expect("tempdir");
2197        let root = tmp.path();
2198        std::fs::create_dir_all(root.join("Models")).unwrap();
2199        std::fs::create_dir_all(root.join("Services")).unwrap();
2200
2201        // Contract interface in the Models namespace.
2202        std::fs::write(
2203            root.join("Models/IEngine.cs"),
2204            "namespace MyApp.Models;\n\npublic interface IEngine\n{\n    int Power { get; }\n}\n",
2205        )
2206        .unwrap();
2207        // Concrete class implementing the interface (base_list -> IEngine).
2208        std::fs::write(
2209            root.join("Models/Engine.cs"),
2210            "namespace MyApp.Models;\n\n\
2211             public class Engine : IEngine\n{\n    public int Power => 1;\n}\n",
2212        )
2213        .unwrap();
2214        // Service in a DIFFERENT namespace, reaching the contract via `using`,
2215        // injected by interface — never `new Engine()`.
2216        std::fs::write(
2217            root.join("Services/Motor.cs"),
2218            "using MyApp.Models;\n\nnamespace MyApp.Services;\n\n\
2219             public class Motor\n{\n    private readonly IEngine _engine;\n\n\
2220             \x20   public Motor(IEngine engine)\n    {\n        _engine = engine;\n    }\n}\n",
2221        )
2222        .unwrap();
2223        // Unrelated control: must NOT appear in the blast radius (non-vacuous).
2224        std::fs::write(
2225            root.join("Services/Logger.cs"),
2226            "namespace MyApp.Services;\n\n\
2227             public class Logger\n{\n    public void Log(string m) { }\n}\n",
2228        )
2229        .unwrap();
2230
2231        let root_str = root.to_string_lossy().to_string();
2232
2233        // Full MCP path: build then analyze (text), exactly like the tool runs.
2234        let build = handle("build", None, &root_str, None, Some("text"));
2235        assert!(!build.contains("ERROR"), "graph build failed: {build}");
2236
2237        let iface = handle(
2238            "analyze",
2239            Some("Models/IEngine.cs"),
2240            &root_str,
2241            None,
2242            Some("text"),
2243        );
2244        assert!(
2245            iface.contains("Models/Engine.cs"),
2246            "implementor (base list) must be impacted by IEngine.cs; got: {iface}"
2247        );
2248        assert!(
2249            iface.contains("Services/Motor.cs"),
2250            "cross-namespace DI consumer (using + interface ctor param) must be \
2251             impacted by IEngine.cs; got: {iface}"
2252        );
2253        assert!(
2254            !iface.contains("Services/Logger.cs"),
2255            "unrelated file must NOT be impacted; got: {iface}"
2256        );
2257    }
2258
2259    /// GH #398 (root cause of the persistent reports): `ctx_impact analyze` is
2260    /// asked for the impact of a *class name* — `ctx_impact analyze ArcPoint` —
2261    /// not a file path. Before the symbol-name fallback the bare name matched no
2262    /// file node, so impact returned a misleading "leaf node / no impact". The
2263    /// fallback must resolve the class to its defining file and surface the real
2264    /// consumers, while a genuinely unknown target gets an actionable
2265    /// diagnostic rather than a false "no impact". Exercises the full
2266    /// `handle("analyze")` path on both builder paths (gated on `tree-sitter`).
2267    #[cfg(feature = "tree-sitter")]
2268    #[test]
2269    fn csharp_analyze_by_class_name_resolves_to_file() {
2270        let _env = crate::core::data_dir::test_env_lock();
2271        let tmp = tempfile::tempdir().expect("tempdir");
2272        let root = tmp.path();
2273        std::fs::create_dir_all(root.join("Models")).unwrap();
2274        std::fs::create_dir_all(root.join("Services")).unwrap();
2275
2276        std::fs::write(
2277            root.join("Models/Engine.cs"),
2278            "namespace App.Core;\n\n\
2279             public class Engine\n{\n    public int Power { get; set; }\n}\n",
2280        )
2281        .unwrap();
2282        std::fs::write(
2283            root.join("Services/Motor.cs"),
2284            "namespace App.Core;\n\n\
2285             public class Motor\n{\n    private readonly Engine _engine;\n\n\
2286             \x20   public Motor(Engine engine)\n    {\n        _engine = engine;\n    }\n}\n",
2287        )
2288        .unwrap();
2289
2290        let root_str = root.to_string_lossy().to_string();
2291        let build = handle("build", None, &root_str, None, Some("text"));
2292        assert!(!build.contains("ERROR"), "graph build failed: {build}");
2293
2294        // The actual user/LLM call: a bare class name, NOT a file path.
2295        let by_name = handle("analyze", Some("Engine"), &root_str, None, Some("text"));
2296        assert!(
2297            by_name.contains("Services/Motor.cs"),
2298            "class-name analyze must resolve 'Engine' to its file and surface the \
2299             DI consumer; got: {by_name}"
2300        );
2301        assert!(
2302            by_name.contains("defined in") && by_name.contains("Models/Engine.cs"),
2303            "class-name analyze must disclose the resolved definer file; got: {by_name}"
2304        );
2305
2306        // A file path must still work unchanged (regression guard).
2307        let by_path = handle(
2308            "analyze",
2309            Some("Models/Engine.cs"),
2310            &root_str,
2311            None,
2312            Some("text"),
2313        );
2314        assert!(
2315            by_path.contains("Services/Motor.cs"),
2316            "file-path analyze must keep working; got: {by_path}"
2317        );
2318
2319        // An unknown target gets a diagnostic, not a false "leaf node".
2320        let unknown = handle(
2321            "analyze",
2322            Some("NoSuchThing"),
2323            &root_str,
2324            None,
2325            Some("text"),
2326        );
2327        assert!(
2328            unknown.contains("not a known file or symbol"),
2329            "unknown target must produce the actionable diagnostic; got: {unknown}"
2330        );
2331
2332        // JSON shape carries the resolution provenance for programmatic callers.
2333        let json = handle("analyze", Some("Engine"), &root_str, None, Some("json"));
2334        assert!(
2335            json.contains("\"resolved_from\": \"symbol\""),
2336            "json must mark symbol-resolved analyses; got: {json}"
2337        );
2338    }
2339
2340    /// The decisive real-world #398 case: the class name does **not** match its
2341    /// file name (`ArcPoint` lives in `Shapes.cs`), which is exactly why a
2342    /// user/LLM types `ctx_impact ArcPoint` instead of a path — and why the old
2343    /// file-only lookup always missed. Both a cross-namespace `using` consumer
2344    /// and a same-namespace no-`using` consumer must surface; an unrelated file
2345    /// must not (non-vacuous).
2346    #[test]
2347    fn csharp_analyze_by_class_name_when_filename_differs() {
2348        let _env = crate::core::data_dir::test_env_lock();
2349        let tmp = tempfile::tempdir().expect("tempdir");
2350        let root = tmp.path();
2351        std::fs::create_dir_all(root.join("Geometry")).unwrap();
2352        std::fs::create_dir_all(root.join("Rendering")).unwrap();
2353        std::fs::create_dir_all(root.join("Misc")).unwrap();
2354
2355        // Class name != file name — the crux of the reporter's scenario.
2356        std::fs::write(
2357            root.join("Geometry/Shapes.cs"),
2358            "namespace App.Geometry;\n\n\
2359             public class ArcPoint\n{\n    public double X { get; set; }\n\
2360             \x20   public double Y { get; set; }\n}\n",
2361        )
2362        .unwrap();
2363        // Cross-namespace consumer WITH a `using`, DI via constructor.
2364        std::fs::write(
2365            root.join("Rendering/Canvas.cs"),
2366            "using App.Geometry;\n\nnamespace App.Rendering;\n\n\
2367             public class Canvas\n{\n    private readonly ArcPoint _origin;\n\n\
2368             \x20   public Canvas(ArcPoint origin)\n    {\n        _origin = origin;\n    }\n}\n",
2369        )
2370        .unwrap();
2371        // Same-namespace consumer WITHOUT any `using`, field injection.
2372        std::fs::write(
2373            root.join("Geometry/Grid.cs"),
2374            "namespace App.Geometry;\n\n\
2375             public class Grid\n{\n    private ArcPoint _topLeft = new();\n}\n",
2376        )
2377        .unwrap();
2378        // Unrelated file in a third namespace: must stay out of the blast radius.
2379        std::fs::write(
2380            root.join("Misc/Clock.cs"),
2381            "namespace App.Misc;\n\n\
2382             public class Clock\n{\n    public long Ticks { get; set; }\n}\n",
2383        )
2384        .unwrap();
2385
2386        let root_str = root.to_string_lossy().to_string();
2387        let build = handle("build", None, &root_str, None, Some("text"));
2388        assert!(!build.contains("ERROR"), "graph build failed: {build}");
2389
2390        let out = handle("analyze", Some("ArcPoint"), &root_str, None, Some("text"));
2391        assert!(
2392            out.contains("Geometry/Shapes.cs"),
2393            "name must resolve to the (differently named) definer file; got: {out}"
2394        );
2395        assert!(
2396            out.contains("Rendering/Canvas.cs"),
2397            "cross-namespace `using` DI consumer must be in the blast radius; got: {out}"
2398        );
2399        assert!(
2400            out.contains("Geometry/Grid.cs"),
2401            "same-namespace no-`using` consumer must be in the blast radius; got: {out}"
2402        );
2403        assert!(
2404            !out.contains("Misc/Clock.cs"),
2405            "unrelated file must NOT appear (non-vacuous); got: {out}"
2406        );
2407    }
2408}