diff --git a/crates/ecp-cli/src/commands/impact.rs b/crates/ecp-cli/src/commands/impact.rs deleted file mode 100644 index 6d8b01bf..00000000 --- a/crates/ecp-cli/src/commands/impact.rs +++ /dev/null @@ -1,2143 +0,0 @@ -use crate::commands::format::{kind_to_str, node_kind_to_str, rel_to_str}; -use crate::commands::symbol_id::{format_fqn, resolve_owner_class, split_fqn_target}; -use crate::engine::Engine; -use crate::git::{DiffScope, GitDiffProvider, ShellGitProvider}; -use crate::output::{emit_with_caveat, merge_caveats, OutputFormat}; -use crate::reanalyze::make_pipeline_for_names; -use clap::{Args, ValueEnum}; -use ecp_core::algorithms::process_trace::is_test_path; -use ecp_core::config; -use ecp_core::graph::{NodeKind, RelType}; -use ecp_core::session::{OverlayView, ViewEdge}; -use ecp_core::{EcpError, HIGH_TRUST_CONFIDENCE}; -use rayon::prelude::*; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; - -#[derive(ValueEnum, Clone, Debug, PartialEq)] -pub enum Direction { - #[value(alias = "upstream")] - Up, - #[value(alias = "downstream")] - Down, - Both, -} - -/// Default heuristic-edge confidence gate; mirrored by all three -/// `confidence_threshold: 0.85` sites (ImpactArgs default, build_payload's -/// inner construction, review/aggregate.rs). -pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.85; - -/// Symbol-level blast radius. From `` traverses call-graph for upstream -/// callers / downstream callees. From `--baseline ` -/// detects symbols changed vs the baseline and runs the same traversal per -/// change. For edge-level resolver delta (tier degradation, silent break), -/// use `ecp diff --section bindings` instead. -#[derive(Args, Debug)] -pub struct ImpactArgs { - /// Target symbol name (mutually exclusive with --baseline). Equivalent to - /// the `--target` named form below. Optional when `--batch` is set or - /// when a non-positional mode flag (`--target`, `--baseline`, `--literal`, - /// `--literal-coherence`) supplies the query. - #[arg(required_unless_present_any = ["target", "batch", "baseline", "literal", "literal_coherence"])] - pub name: Option, - - /// Named alias for the positional NAME argument — kept for parity with - /// old MCP / wrapper habits. - #[arg(long = "target", value_name = "TARGET", conflicts_with_all = ["name", "baseline", "batch"])] - pub target: Option, - - /// Git ref — compute blast radius across all symbols changed between - /// this baseline and HEAD. Mutually exclusive with positional . - #[arg(long, conflicts_with_all = ["name", "batch"])] - pub baseline: Option, - - /// Disambiguate when name has multiple matches: substring on file path. - /// `--file_path` / `--file-path` stay as aliases for back-compat. - #[arg(long = "file", alias = "file_path", alias = "file-path")] - pub file: Option, - - /// Disambiguate by kind (function | method | class | route | ...). - #[arg(long)] - pub kind: Option, - - /// Direction of traversal. - #[arg(long, value_enum, default_value_t = Direction::Up)] - pub direction: Direction, - - /// Maximum BFS depth. - #[arg(long, default_value_t = 5)] - pub depth: usize, - - /// Default OFF — recall-first: traverse every edge regardless of - /// confidence (cross-crate refs at 0.7 are still real callers, just - /// less certain). Pass `--high-trust-only=true` to restrict to - /// confidence ≥ 0.8 edges for a noise-light view; when filtering kicks - /// in, the output reports `hidden_edges` so missed coverage stays - /// visible. - #[arg(long, alias = "high_trust_only", default_value_t = false, action = clap::ArgAction::Set)] - pub high_trust_only: bool, - - /// Override the high-trust threshold with a custom value (0.0–1.0). - /// If set, takes precedence over --high-trust-only. - #[arg(long, alias = "min_confidence")] - pub min_confidence: Option, - - /// Include test files in traversal. - #[arg(long, aliases = ["include_tests", "includeTests"], default_value_t = false)] - pub include_tests: bool, - - /// Comma-separated relation types to follow (calls, extends, ...). - #[arg(long = "relation_types", alias = "relation-types")] - pub relation_types: Option, - - /// Repository selector. - #[arg(long)] - pub repo: Option, - - /// Coverage gap analysis: for each touched symbol, classify by test-caller - /// presence (uncovered / partial / covered). Uses FunctionMeta.is_test - /// flag from per-language extraction. Outputs uncovered symbols first to - /// support LLM PR review ("X 改了沒測試"). Implies --include-tests during - /// traversal so test callers are reachable from the walker. - #[arg(long, aliases = ["test_coverage", "testCoverage"], default_value_t = false)] - pub test_coverage: bool, - - /// Suppress heuristic callers (MirrorsField, EventTopicMirror) from the - /// blast radius. Default: heuristic callers ARE shown, in a separate - /// `heuristic_callers` bucket tagged `requires_verification`. Pass this - /// flag for a pure-deterministic blast radius. - #[arg(long, default_value_t = false)] - pub no_heuristic: bool, - - /// Informational confidence gate — promotes heuristic edges when T4-7/T5-33 - /// emit per-edge tiers. Currently controls the --explain-confidence report. - #[arg(long, default_value_t = DEFAULT_CONFIDENCE_THRESHOLD)] - pub confidence_threshold: f32, - - /// Emit explain_confidence block with threshold + per-tier filtered counts. - #[arg(long, default_value_t = false)] - pub explain_confidence: bool, - - /// Output format (mostly internal — agent doesn't set this). - #[arg(long)] - pub format: Option, - - /// List sites of a path-shaped string literal by exact value. - /// Mutually exclusive with --target/--baseline/. Returns JSON - /// with each site's file, line, enclosing fn, and sink classification - /// (`sink:read` / `sink:write` / `sink:open-read` / `sink:join` / etc). - /// Designed for LLM split-brain queries: `ecp impact --literal - /// session_meta.json` answers "where is this file read or written?" - /// without writing cypher. - #[arg(long = "literal", value_name = "VALUE", conflicts_with_all = ["name", "target", "baseline"])] - pub literal: Option, - - /// Auto-detect likely path-literal split-brain pairs across all - /// PathLiteral nodes. Conservative: same extension, similar basename, - /// nearby directories, and read-only vs write-only sink separation. - #[arg(long = "literal-coherence", conflicts_with_all = ["name", "target", "baseline", "literal", "batch"])] - pub literal_coherence: bool, - - /// Read target symbol names from stdin (one per line; `#` and blank lines - /// skipped). The graph is loaded once and N symbols are resolved - /// sequentially — amortises mmap + process spawn across queries. - /// Each result is prefixed by `=== target: ===` so callers can - /// split the stream unambiguously. Flags like --direction / --depth / - /// --include-tests apply uniformly to all targets. - /// - /// Symbol-mode only: `--batch` combined with `--baseline` or `--literal` - /// is rejected as an invalid argument. A positional name is also rejected - /// — stdin is the single source of targets, so a positional would be - /// silently ignored otherwise. - #[arg(long, conflicts_with_all = ["name", "baseline", "literal", "literal_coherence"])] - pub batch: bool, -} - -// ── Test-coverage gap analysis ──────────────────────────────────────────────── - -/// Classification of a symbol's test coverage. -#[derive(Debug, Clone, PartialEq, Eq)] -enum CoverageClass { - /// Callers exist in prod but zero test callers. - Uncovered, - /// test_caller_count >= 1, but prod callers outnumber tests by > 3:1. - Partial, - /// test_caller_count >= 1 and prod:test ratio <= 3:1. - Covered, - /// No callers at all (entry-point / dead code path). - Orphan, -} - -/// Data collected for a single symbol during coverage analysis. -struct SymbolCoverage { - uid: String, - name: String, - file: String, - line: u32, - kind: String, - test_callers: Vec, - test_caller_count: usize, - prod_caller_count: usize, - class: CoverageClass, -} - -/// Check whether a caller node is a test using FunctionMeta.is_test(). -/// -/// The archived `function_metas` vec is sorted by `node_idx`, so we use -/// binary search. On the archived type, `flags` is `ArchivedU16` and requires -/// `.to_native()` before bitwise ops. -fn archived_is_test(graph: &ecp_core::graph::ArchivedZeroCopyGraph, node_idx: usize) -> bool { - use ecp_core::graph::FunctionMeta; - let target = node_idx as u32; - match graph - .function_metas - .binary_search_by_key(&target, |m| m.node_idx.to_native()) - { - Ok(i) => graph.function_metas[i].flags.to_native() & FunctionMeta::FLAG_TEST != 0, - Err(_) => false, - } -} - -/// Classify a single symbol given its upstream callers (BFS result). -/// -/// `bfs_results` is the slice returned by `run_bfs`; depth-0 entry is the -/// symbol itself. Only depth > 0 entries (actual callers) are examined. -/// -/// `uid_idx` is the pre-built `uid → node_idx` table from -/// [`ecp_core::graph_query::build_uid_index`]. Building it once per -/// coverage analysis and passing it here avoids an O(N) linear scan over -/// all graph nodes for every BFS caller entry (T1-6 fast-path). -fn classify_symbol( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - view: Option<&OverlayView>, - symbol_idx: usize, - bfs_results: &[Value], - uid_idx: &rustc_hash::FxHashMap, -) -> SymbolCoverage { - let meta = merged_node_meta(graph, view, symbol_idx); - let uid = meta.uid.to_string(); - let name = meta.name; - let file = meta.file_path; - let line = meta.line; - let kind = meta.kind.to_string(); - - let mut test_callers: Vec = Vec::new(); - let mut prod_caller_count: usize = 0; - - for entry in bfs_results - .iter() - .filter(|e| e["depth"].as_u64().unwrap_or(0) > 0) - { - // O(1) uid → node_idx via pre-built FxHashMap (T1-6 fast-path). - // BFS JSON stores uid as a decimal string; parse back to u64 for lookup. - let caller_uid = entry["uid"].as_str().unwrap_or(""); - let caller_idx = caller_uid - .parse::() - .ok() - .and_then(|u| uid_idx.get(&u).map(|&i| i as usize)); - - let is_test = caller_idx - .map(|idx| archived_is_test(graph, idx)) - .unwrap_or(false); - - if is_test { - let caller_name = entry["name"].as_str().unwrap_or(caller_uid).to_string(); - test_callers.push(caller_name); - } else { - prod_caller_count += 1; - } - } - - let test_caller_count = test_callers.len(); - let class = match (test_caller_count, prod_caller_count) { - (0, 0) => CoverageClass::Orphan, - (0, _) => CoverageClass::Uncovered, - (t, p) if p > t * 3 => CoverageClass::Partial, - _ => CoverageClass::Covered, - }; - - SymbolCoverage { - uid, - name, - file, - line, - kind, - test_callers, - test_caller_count, - prod_caller_count, - class, - } -} - -#[allow(clippy::too_many_arguments)] -fn coverage_bfs_for_symbol( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - view: Option<&OverlayView>, - symbol_idx: usize, - requested_direction: &Direction, - existing_bfs: &[Value], - depth: usize, - min_conf: f32, - include_tests: bool, - rel_filter: &Option>, -) -> Vec { - if *requested_direction == Direction::Up { - return existing_bfs.to_vec(); - } - // Coverage analysis only consumes deterministic upstream callers — discard - // the heuristic / hidden-count fields from #264's expanded run_bfs return. - let (det_results, _heur, _hidden_conf, _hidden_heur) = run_bfs( - graph, - view, - symbol_idx, - &Direction::Up, - depth, - min_conf, - include_tests, - rel_filter, - false, // include_heuristic - ); - det_results -} - -#[allow(clippy::too_many_arguments)] -fn coverage_analyses( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - view: Option<&OverlayView>, - bfs_by_symbol: &[(usize, Vec)], - requested_direction: &Direction, - depth: usize, - min_conf: f32, - include_tests: bool, - rel_filter: &Option>, -) -> Vec { - // Build uid → node_idx once for the whole analysis. classify_symbol - // needs to reverse-look-up caller uids (from BFS JSON) back to node - // indices to call archived_is_test; without this table each caller - // entry would do an O(N) linear scan. (T1-6 fast-path) - let uid_idx = ecp_core::graph_query::build_uid_index(graph); - bfs_by_symbol - .iter() - .map(|(idx, bfs)| { - let coverage_bfs = coverage_bfs_for_symbol( - graph, - view, - *idx, - requested_direction, - bfs, - depth, - min_conf, - include_tests, - rel_filter, - ); - classify_symbol(graph, view, *idx, &coverage_bfs, &uid_idx) - }) - .collect() -} - -/// Build the `coverage` JSON section from a list of per-symbol analyses. -fn build_coverage_json(analyses: Vec) -> Value { - let mut uncovered: Vec = Vec::new(); - let mut partial: Vec = Vec::new(); - let mut covered: Vec = Vec::new(); - let mut orphans: Vec = Vec::new(); - - for s in analyses { - let base = json!({ - "uid": s.uid, - "name": s.name, - "file": s.file, - "line": s.line, - "kind": s.kind, - "test_caller_count": s.test_caller_count, - "prod_caller_count": s.prod_caller_count, - }); - match s.class { - CoverageClass::Uncovered => uncovered.push(base), - CoverageClass::Partial => { - let mut v = base; - v["tests"] = json!(s.test_callers); - partial.push(v); - } - CoverageClass::Covered => { - let mut v = base; - v["tests"] = json!(s.test_callers); - covered.push(v); - } - CoverageClass::Orphan => orphans.push(base), - } - } - - let total_analyzed = uncovered.len() + partial.len() + covered.len() + orphans.len(); - json!({ - "summary": { - "uncovered": uncovered.len(), - "partial": partial.len(), - "covered": covered.len(), - "orphan": orphans.len(), - "total_analyzed": total_analyzed, - }, - "uncovered_symbols": uncovered, - "partial_symbols": partial, - "covered_symbols": covered, - "orphan_symbols": orphans, - }) -} - -/// Split a comma-separated flag value into a normalized lowercase Vec. -/// Empty / whitespace-only parts are dropped so `--kind ,function,` works. -fn parse_csv_lower(s: Option<&str>) -> Option> { - s.map(|raw| { - raw.split(',') - .map(|p| p.trim().to_ascii_lowercase()) - .filter(|p| !p.is_empty()) - .collect() - }) -} - -/// Hints produced during impact computation and routed by `run`: stderr -/// nudges for the human/agent reading the terminal, plus a payload caveat. -/// Library callers via `build_payload` stay stderr-clean. -#[derive(Default)] -struct ImpactHints { - empty_hint_name: Option, - /// The empty-hint target is a field (Property); the hint adds the - /// field-read-coverage caveat. - empty_hint_is_field: bool, - /// If > 0, emit the hidden-edges footer. - hidden_edges: u64, - /// Heuristic edges hidden by the is_heuristic() filter (T-H1). - hidden_heuristic_edges: u64, - /// Payload caveat: the target name collides with other definitions, so - /// bare calls were Tier-3-suppressed at index time and the caller set is - /// a lower bound. Merged with `Engine::caveat()` into the `result` field - /// by `run`. - ambiguity_caveat: Option, -} - -pub fn run(args: ImpactArgs, engine: &Engine) -> Result<(), EcpError> { - if args.batch { - return run_batch(args, engine); - } - let format = OutputFormat::parse(args.format.as_deref()); - if args.literal_coherence { - let payload = build_literal_coherence_payload(engine)?; - return emit_with_caveat(&payload, format, engine.caveat()); - } - if let Some(literal_value) = args.literal.clone() { - let payload = build_literal_payload(&literal_value, engine)?; - return emit_with_caveat(&payload, format, engine.caveat()); - } - let (payload, hints) = build_payload_with_hints(&args, engine)?; - if let Some(name) = &hints.empty_hint_name { - eprintln!( - "→ \"{name}\" exists but has 0 incoming references. Possible: entry point, dead code, or recent rename. Try --direction both / --include-tests" - ); - if hints.empty_hint_is_field { - eprintln!( - "→ \"{name}\" is a field: some languages don't capture field reads yet (JS class fields, Ruby attrs), so empty may mean uncaptured, not unread — grep to confirm" - ); - } - } - emit_hidden_edges_footer(hints.hidden_edges); - if args.no_heuristic && hints.hidden_heuristic_edges > 0 { - eprintln!( - "note: {} heuristic callers suppressed (--no-heuristic); drop the flag to see them", - hints.hidden_heuristic_edges - ); - } - emit_with_caveat( - &payload, - format, - merge_caveats(engine.caveat(), hints.ambiguity_caveat), - ) -} - -// ── Batch dispatch ──────────────────────────────────────────────────────────── - -/// Read target names from stdin, one per line (`#` and blank lines skipped). -/// -/// The graph is loaded once (by the caller via `Engine`) and N symbols are -/// resolved sequentially against it — amortises the mmap + process-spawn cost -/// that agents would otherwise pay for each single-target call. -/// -/// Output: each target's block is prefixed by `=== target: ===` so -/// callers can split the stream unambiguously regardless of `--format`. -/// Per-target fields are identical to single-target mode (status, target, -/// direction, impact, …). A target that fails to resolve (not found, ambiguous) -/// gets a per-target JSON error entry — the batch never aborts early. -fn run_batch(args: ImpactArgs, engine: &Engine) -> Result<(), EcpError> { - use std::io::BufRead; - - let format = OutputFormat::parse(args.format.as_deref()); - - let stdin = std::io::stdin(); - let targets: Vec = stdin - .lock() - .lines() - .map_while(Result::ok) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty() && !s.starts_with('#')) - .collect(); - - if targets.is_empty() { - eprintln!("→ batch: no targets on stdin (one symbol name per line, `#` for comments)"); - return Ok(()); - } - - let caveat = engine.caveat(); - - for target_name in &targets { - println!("=== target: {target_name} ==="); - - // Clone flags; substitute this target's name. - let per_target_args = ImpactArgs { - name: Some(target_name.clone()), - target: None, - baseline: None, - file: args.file.clone(), - kind: args.kind.clone(), - direction: args.direction.clone(), - depth: args.depth, - high_trust_only: args.high_trust_only, - min_confidence: args.min_confidence, - include_tests: args.include_tests, - relation_types: args.relation_types.clone(), - repo: args.repo.clone(), - test_coverage: args.test_coverage, - no_heuristic: args.no_heuristic, - confidence_threshold: args.confidence_threshold, - explain_confidence: args.explain_confidence, - format: args.format.clone(), - literal: None, - literal_coherence: false, - batch: false, - }; - - let payload = match build_payload_with_hints(&per_target_args, engine) { - Ok((p, hints)) => { - emit_hidden_edges_footer(hints.hidden_edges); - let merged = merge_caveats(caveat.clone(), hints.ambiguity_caveat); - // Inline the caveat into the payload so per-target output is - // self-contained (same contract as single-target mode's - // `emit_with_caveat`). - let mut p = p; - if let Some(c) = merged { - p["result"] = serde_json::json!(c); - } - p - } - Err(e) => { - serde_json::json!({ - "error": e.to_string(), - "target": target_name, - "status": "not_found", - }) - } - }; - emit_with_caveat(&payload, format, None)?; - } - Ok(()) -} - -/// Library API: returns the JSON payload only, dropping stderr hints. -/// -/// `run` (binary path) calls `build_payload_with_hints` directly so it can -/// print the hints to stderr, which means this thin wrapper has no in-crate -/// caller and `cargo` flags it as dead. Kept `pub` to mirror the 5-command -/// `build_payload` surface introduced in PR #88 for future library consumers. -#[allow(dead_code)] -pub fn build_payload(args: &ImpactArgs, engine: &Engine) -> Result { - build_payload_with_hints(args, engine).map(|(v, _)| v) -} - -/// Build the payload for `ecp impact --literal `. Returns a JSON -/// object with the literal value and an array of sites: each site carries -/// file path, line, enclosing function name (if resolved), and the sink -/// classification (`sink:read|confidence:high`, `sink:write|confidence:medium`, -/// `sink:free|confidence:high`, etc.). -fn build_literal_payload(value: &str, engine: &Engine) -> Result { - use ecp_core::graph::ArchivedRelType; - - let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; - - // Pre-build (target_node_idx → edge) map for UsesPathLiteral edges in a - // single O(|edges|) pass. Without this, the per-match `edges.iter().find` - // below was O(matches × edges) — at ~500k edges and a popular literal - // matching dozens of sites, that's tens of millions of comparisons and - // breaches the <30 ms per-query budget. - let lit_edge: HashMap = graph - .edges - .iter() - .filter(|e| matches!(e.rel_type, ArchivedRelType::UsesPathLiteral)) - .map(|e| (e.target.to_native(), e)) - .collect(); - - let mut sites: Vec = Vec::new(); - // `nodes_by_kind` walks the v10 CSR (kind_offsets / kind_node_idx) so we - // touch only PathLiteral entries, not the full nodes vec. - for idx_u32 in graph.nodes_by_kind(NodeKind::PathLiteral) { - let idx = idx_u32 as usize; - let node = &graph.nodes[idx]; - if node.name.resolve(&graph.string_pool) != value { - continue; - } - let file_node = &graph.files[node.file_idx.to_native() as usize]; - let file_path = file_node.path.resolve(&graph.string_pool); - - let (enclosing_name, sink_reason) = lit_edge - .get(&idx_u32) - .map(|e| { - let src_idx = e.source.to_native() as usize; - let src_name = graph.nodes[src_idx] - .name - .resolve(&graph.string_pool) - .to_string(); - let reason = e.reason.resolve(&graph.string_pool).to_string(); - (Some(src_name), reason) - }) - .unwrap_or((None, String::new())); - - sites.push(serde_json::json!({ - "file": file_path, - "line": node.start_line(), - "col": node.span.1.to_native(), - "enclosing": enclosing_name, - "sink_reason": sink_reason, - })); - } - - Ok(serde_json::json!({ - "literal": value, - "site_count": sites.len(), - "sites": sites, - })) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LiteralAccess { - ReadOnly, - WriteOnly, - MixedOrUnknown, -} - -#[derive(Debug, Clone)] -struct LiteralSite { - file: String, - line: u32, - col: u32, - enclosing: Option, - sink_reason: String, -} - -#[derive(Debug, Clone)] -struct LiteralGroup { - value: String, - sites: Vec, -} - -impl LiteralGroup { - fn access(&self) -> LiteralAccess { - let mut read_count = 0usize; - let mut write_count = 0usize; - - for site in &self.sites { - match sink_kind(&site.sink_reason) { - Some("read") | Some("open-read") => read_count += 1, - Some("write") | Some("open-write") => write_count += 1, - _ => return LiteralAccess::MixedOrUnknown, - } - } - - match (read_count, write_count) { - (r, 0) if r > 0 => LiteralAccess::ReadOnly, - (0, w) if w > 0 => LiteralAccess::WriteOnly, - _ => LiteralAccess::MixedOrUnknown, - } - } -} - -pub fn build_literal_coherence_payload(engine: &Engine) -> Result { - let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; - let groups = collect_literal_groups(graph); - let candidates = literal_coherence_candidates(&groups); - - Ok(json!({ - "literal_coherence": { - "candidate_count": candidates.len(), - "candidates": candidates, - "rules": { - "min_similarity": 0.85, - "same_extension": true, - "nearby_directory": true, - "access_split": "read-only vs write-only" - } - } - })) -} - -fn collect_literal_groups(graph: &ecp_core::graph::ArchivedZeroCopyGraph) -> Vec { - use ecp_core::graph::ArchivedRelType; - - let lit_edge: HashMap = graph - .edges - .iter() - .filter(|e| matches!(e.rel_type, ArchivedRelType::UsesPathLiteral)) - .map(|e| (e.target.to_native(), e)) - .collect(); - - let mut groups: HashMap = HashMap::new(); - for idx_u32 in graph.nodes_by_kind(NodeKind::PathLiteral) { - let node = &graph.nodes[idx_u32 as usize]; - let value = node.name.resolve(&graph.string_pool).to_string(); - let file_node = &graph.files[node.file_idx.to_native() as usize]; - let file = file_node.path.resolve(&graph.string_pool).to_string(); - let (enclosing, sink_reason) = lit_edge - .get(&idx_u32) - .map(|e| { - let src_idx = e.source.to_native() as usize; - let src_name = graph.nodes[src_idx] - .name - .resolve(&graph.string_pool) - .to_string(); - let reason = e.reason.resolve(&graph.string_pool).to_string(); - (Some(src_name), reason) - }) - .unwrap_or((None, String::new())); - - let group = groups.entry(value.clone()).or_insert_with(|| LiteralGroup { - value, - sites: Vec::new(), - }); - group.sites.push(LiteralSite { - file, - line: node.start_line(), - col: node.span.1.to_native(), - enclosing, - sink_reason, - }); - } - - groups.into_values().collect() -} - -fn literal_coherence_candidates(groups: &[LiteralGroup]) -> Vec { - const MIN_SIMILARITY: f64 = 0.85; - - let mut out = Vec::new(); - for (i, left) in groups.iter().enumerate() { - let left_access = left.access(); - if left_access == LiteralAccess::MixedOrUnknown { - continue; - } - for right in groups.iter().skip(i + 1) { - let right_access = right.access(); - if !matches!( - (left_access, right_access), - (LiteralAccess::ReadOnly, LiteralAccess::WriteOnly) - | (LiteralAccess::WriteOnly, LiteralAccess::ReadOnly) - ) { - continue; - } - if !same_extension(&left.value, &right.value) || !nearby_directory(left, right) { - continue; - } - - let similarity = literal_similarity(&left.value, &right.value); - if similarity < MIN_SIMILARITY { - continue; - } - - let (reader, writer) = if left_access == LiteralAccess::ReadOnly { - (left, right) - } else { - (right, left) - }; - out.push(json!({ - "reader_literal": reader.value, - "writer_literal": writer.value, - "similarity": similarity, - "confidence": "high", - "reader_sites": sites_json(&reader.sites), - "writer_sites": sites_json(&writer.sites), - })); - } - } - out.sort_by(|a, b| { - let a_similarity = a["similarity"].as_f64().unwrap_or(0.0); - let b_similarity = b["similarity"].as_f64().unwrap_or(0.0); - b_similarity - .partial_cmp(&a_similarity) - .unwrap_or(std::cmp::Ordering::Equal) - }); - out -} - -fn sink_kind(reason: &str) -> Option<&str> { - reason.strip_prefix("sink:")?.split('|').next() -} - -fn sites_json(sites: &[LiteralSite]) -> Vec { - sites - .iter() - .map(|site| { - json!({ - "file": site.file, - "line": site.line, - "col": site.col, - "enclosing": site.enclosing, - "sink_reason": site.sink_reason, - }) - }) - .collect() -} - -fn same_extension(left: &str, right: &str) -> bool { - path_extension(left).is_some_and(|ext| Some(ext) == path_extension(right)) -} - -fn path_extension(path: &str) -> Option<&str> { - path.rsplit_once('.') - .map(|(_, ext)| ext) - .filter(|ext| !ext.is_empty() && !ext.contains('/') && !ext.contains('\\')) -} - -fn nearby_directory(left: &LiteralGroup, right: &LiteralGroup) -> bool { - left.sites.iter().any(|l| { - let ldir = parent_dir(&l.file); - right.sites.iter().any(|r| ldir == parent_dir(&r.file)) - }) -} - -fn parent_dir(path: &str) -> &str { - path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("") -} - -fn literal_similarity(left: &str, right: &str) -> f64 { - let left_base = basename_without_ext(left).to_ascii_lowercase(); - let right_base = basename_without_ext(right).to_ascii_lowercase(); - normalized_levenshtein(&left_base, &right_base) - .max(containment_similarity(&left_base, &right_base)) -} - -fn basename_without_ext(path: &str) -> &str { - let name = path.rsplit_once('/').map(|(_, n)| n).unwrap_or(path); - name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name) -} - -fn containment_similarity(left: &str, right: &str) -> f64 { - let (short, long) = if left.len() <= right.len() { - (left, right) - } else { - (right, left) - }; - if short.len() < 4 { - return 0.0; - } - let boundary_match = long == short - || long.starts_with(&format!("{short}_")) - || long.ends_with(&format!("_{short}")) - || long.contains(&format!("_{short}_")) - || long.starts_with(&format!("{short}-")) - || long.ends_with(&format!("-{short}")) - || long.contains(&format!("-{short}-")); - if boundary_match { - 0.9 - } else { - 0.0 - } -} - -fn normalized_levenshtein(left: &str, right: &str) -> f64 { - let max_len = left.chars().count().max(right.chars().count()); - if max_len == 0 { - return 1.0; - } - 1.0 - (levenshtein(left, right) as f64 / max_len as f64) -} - -fn levenshtein(left: &str, right: &str) -> usize { - let right_chars: Vec = right.chars().collect(); - let mut prev: Vec = (0..=right_chars.len()).collect(); - let mut curr = vec![0usize; right_chars.len() + 1]; - - for (i, lc) in left.chars().enumerate() { - curr[0] = i + 1; - for (j, &rc) in right_chars.iter().enumerate() { - let cost = usize::from(lc != rc); - curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); - } - std::mem::swap(&mut prev, &mut curr); - } - prev[right_chars.len()] -} - -// ── Per-symbol library API (used by `ecp group impact`) ───────────────────── - -/// Result of a single-symbol local impact computation. -/// -/// Wraps the JSON payload produced by `impact_by_name` so that callers can -/// extract the symbol UIDs touched by the traversal without re-parsing the -/// full payload themselves. -pub struct LocalImpact { - payload: Value, -} - -impl LocalImpact { - /// UIDs of every node reached by the BFS (depth 0 = the target itself). - /// Returns an empty vec when the payload carries an `"error"` field. - pub fn direct_symbol_uids(&self) -> Vec<&str> { - self.payload["impact"] - .as_array() - .map(|arr| arr.iter().filter_map(|v| v["uid"].as_str()).collect()) - .unwrap_or_default() - } - - /// Number of nodes in the BFS result (excluding the start node at depth 0). - pub fn direct_count(&self) -> usize { - self.payload["impact"] - .as_array() - .map(|arr| { - arr.iter() - .filter(|v| v["depth"].as_u64().unwrap_or(0) > 0) - .count() - }) - .unwrap_or(0) - } - - /// The full JSON payload — same shape as `ecp impact --format json`. - pub fn as_json(&self) -> &Value { - &self.payload - } -} - -/// Per-symbol impact computation callable without a CLI context. -/// -/// `member_repo` is the `dir_name` or alias of the indexed repo; it is used -/// only to resolve the confidence threshold from the repo config — the Engine -/// is provided by the caller, so no graph loading happens here. -/// -/// Returns `Ok(LocalImpact)` even when the symbol is not found in the graph -/// (the payload will carry an `"error"` field in that case), matching the -/// same graceful-degradation behaviour as `ecp impact --target X`. -pub fn run_for_symbol( - engine: &Engine, - member_repo: &str, - target: &str, - direction: &str, - max_depth: Option, - timeout_ms: Option, - include_tests: bool, -) -> Result { - let dir = match direction.to_ascii_lowercase().as_str() { - "downstream" | "down" => Direction::Down, - "both" => Direction::Both, - _ => Direction::Up, - }; - let args = ImpactArgs { - name: Some(target.to_string()), - target: None, - baseline: None, - file: None, - kind: None, - direction: dir, - depth: max_depth.unwrap_or(5) as usize, - high_trust_only: false, - min_confidence: None, - include_tests, - relation_types: None, - repo: Some(member_repo.to_string()), - test_coverage: false, - format: None, - no_heuristic: true, - confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD, - explain_confidence: false, - literal: None, - literal_coherence: false, - batch: false, - }; - let _ = timeout_ms; // timeout enforcement is caller-side; passed for API parity - let (payload, _hints) = build_payload_with_hints(&args, engine)?; - Ok(LocalImpact { payload }) -} - -fn build_payload_with_hints( - args: &ImpactArgs, - engine: &Engine, -) -> Result<(Value, ImpactHints), EcpError> { - let has_name = args.name.is_some() || args.target.is_some(); - match (has_name, args.baseline.as_ref()) { - (true, None) => impact_by_name(args, engine), - (false, Some(_)) => impact_with_baseline(args, engine).map(|v| (v, ImpactHints::default())), - (false, None) => Err(EcpError::InvalidArgument( - "impact requires a symbol (positional or --target ) or --baseline " - .into(), - )), - (true, Some(_)) => unreachable!("clap conflicts_with prevents this"), - } -} - -fn impact_by_name(args: &ImpactArgs, engine: &Engine) -> Result<(Value, ImpactHints), EcpError> { - let name = args - .name - .as_deref() - .or(args.target.as_deref()) - .expect("build_payload_with_hints gates on name||target"); - let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; - let view = engine.overlay_view(); - - // Split `Owner.Method` form for precise targeting. - let (owner_filter, bare_name) = split_fqn_target(name); - - // Resolve name → matching node indices, with optional --file / --kind - // disambiguation. FQN `Owner.Method` form is an additional filter on top. - let file_needle = args.file.as_deref(); - let kind_needle = args.kind.as_deref().map(|s| s.to_ascii_lowercase()); - - let mut same_name_defs = 0usize; - let mut matches: Vec = Vec::new(); - for (idx, node) in graph.nodes.iter().enumerate() { - if node.name.resolve(&graph.string_pool) != bare_name { - continue; - } - // Synthetic nodes (e.g. resolver-miss `Annotation` from - // `decorates_edges`) carry SYNTHETIC_FILE_IDX — they aren't - // real symbols at any file:line. Drop them from impact targets. - if !node.has_owning_file() { - continue; - } - // Working-tree truth: a dirty-file symbol deleted/renamed on disk - // (suppressed by the overlay view) is not a valid impact target, and - // deliberately stops counting toward `same_name_defs` — the ambiguity - // caveat describes the on-disk world, not the stale base graph. - if view.is_some_and(|v| v.redirect(idx as u32).is_none()) { - continue; - } - // Counted BEFORE --kind/--file/FQN narrowing: the Tier-3 resolver - // defence keys on the global name collision, not on whichever - // single def the user disambiguated to. - same_name_defs += 1; - if let Some(ref kn) = kind_needle { - let node_kind = kind_to_str(&node.kind).to_ascii_lowercase(); - if &node_kind != kn { - continue; - } - } - if let Some(needle) = file_needle { - let file_path = graph.files[node.file_idx.to_native() as usize] - .path - .resolve(&graph.string_pool); - if !file_path.contains(needle) { - continue; - } - } - if let Some(owner) = owner_filter { - if !resolve_owner_class(graph, idx) - .map(|oc| oc == owner) - .unwrap_or(false) - { - continue; - } - } - // A replaced base node enters the merged space as its virtual twin - // (on-disk spans; masked stale adjacency handled by run_bfs). - matches.push(match view { - Some(v) => v.redirect(idx as u32).expect("suppressed filtered above") as usize, - None => idx, - }); - } - // Symbols that only exist in the working tree (new functions in dirty - // files) — base-replacing twins are excluded: they arrived via redirect. - if let Some(v) = view { - for (i, vn) in v.virtual_nodes().iter().enumerate() { - if vn.replaced_base.is_some() || vn.name != bare_name { - continue; - } - same_name_defs += 1; - if let Some(ref kn) = kind_needle { - if &node_kind_to_str(&vn.kind).to_ascii_lowercase() != kn { - continue; - } - } - if let Some(needle) = file_needle { - if !vn.rel_path.contains(needle) { - continue; - } - } - if let Some(owner) = owner_filter { - if vn.owner_class.as_deref() != Some(owner) { - continue; - } - } - matches.push(v.base_len() as usize + i); - } - } - - if matches.is_empty() { - return Err(EcpError::InvalidArgument(format!( - "No symbol named '{}' found in graph. Try `ecp find --mode fuzzy` to find candidates, or check --file / --kind filters", - format_fqn(owner_filter, bare_name) - ))); - } - - // Multiple matches without disambiguation → report candidates then fail. - // FQN targeting (owner_filter present) already narrows by owner, so only - // fall into the ambiguous branch when the remaining options still exceed 1. - if matches.len() > 1 && file_needle.is_none() && kind_needle.is_none() { - let fqn_label = format_fqn(owner_filter, bare_name); - let candidates: Vec = matches - .iter() - .map(|&i| { - let meta = merged_node_meta(graph, view, i); - json!({ - "kind": meta.kind, - "filePath": meta.file_path, - "line": meta.line, - }) - }) - .collect(); - let candidate_lines = candidates - .iter() - .map(|candidate| { - format!( - " {},{},{}", - candidate["filePath"].as_str().unwrap_or(""), - candidate["kind"].as_str().unwrap_or(""), - candidate["line"].as_u64().unwrap_or(0) - ) - }) - .collect::>() - .join("\n"); - return Err(EcpError::AmbiguousSymbol { - name: fqn_label.to_string(), - count: matches.len(), - candidates: Some(candidate_lines), - }); - } - - let min_conf = resolve_min_conf(args); - let rel_filter = parse_csv_lower(args.relation_types.as_deref()); - // --test-coverage implies --include-tests so test callers are reachable. - let effective_include_tests = args.include_tests || args.test_coverage; - - let mut all_results: Vec = Vec::new(); - let mut all_heuristic_results: Vec = Vec::new(); - let mut hidden_edges_total: u64 = 0; - let mut hidden_heuristic_total: u64 = 0; - let mut per_match_bfs: Vec<(usize, Vec)> = Vec::new(); - for start_idx in &matches { - let (det_results, heur_results, hidden_conf, hidden_heur) = run_bfs( - graph, - view, - *start_idx, - &args.direction, - args.depth, - min_conf, - effective_include_tests, - &rel_filter, - !args.no_heuristic, - ); - all_results.extend(det_results.iter().cloned()); - per_match_bfs.push((*start_idx, det_results)); - all_heuristic_results.extend(heur_results); - hidden_edges_total += hidden_conf; - hidden_heuristic_total += hidden_heur; - } - - // Empty callers hint for upstream direction. - let impact_without_start: Vec<&Value> = all_results - .iter() - .filter(|e| e["depth"].as_u64().unwrap_or(0) > 0) - .collect(); - let emit_empty_hint = impact_without_start.is_empty() && args.direction == Direction::Up; - // A field target with no readers: the hint must flag that some languages - // don't model field reads yet, so empty != provably unread. - let empty_hint_is_field = emit_empty_hint - && all_results - .iter() - .any(|e| e["depth"].as_u64() == Some(0) && e["kind"].as_str() == Some("property")); - - // Collect unique file paths across ALL matches so the blind-spot warning - // is accurate when --file / --kind still leaves >1 match. - let mut seen_files = HashSet::new(); - let target_file_paths: Vec = matches - .iter() - .map(|&idx| merged_node_meta(graph, view, idx).file_path) - .filter(|p| seen_files.insert(p.clone())) - .collect(); - - let mut all_blind_spot_kinds: Vec = Vec::new(); - for fp in &target_file_paths { - all_blind_spot_kinds.extend(collect_blind_spots(graph, fp)); - } - - // Use the original user-supplied name (which may be FQN) as the target - // label in output — more precise than bare_name when owner was specified. - let mut result_obj = json!({ - "status": "success", - "target": format_fqn(owner_filter, bare_name), - "direction": direction_str(&args.direction), - "impact": all_results, - }); - attach_hidden_edges(&mut result_obj, hidden_edges_total); - attach_heuristic_fields( - &mut result_obj, - hidden_heuristic_total, - all_heuristic_results, - !args.no_heuristic, - args.explain_confidence, - args.confidence_threshold, - ); - - if !all_blind_spot_kinds.is_empty() { - let mut by_kind = std::collections::BTreeMap::::new(); - for k in &all_blind_spot_kinds { - *by_kind.entry(k.clone()).or_insert(0) += 1; - } - let files_field: serde_json::Value = if target_file_paths.len() == 1 { - json!(target_file_paths[0]) - } else { - json!(target_file_paths) - }; - result_obj["blind_spot_warning"] = json!({ - "file": files_field, - "total": all_blind_spot_kinds.len(), - "by_kind": by_kind, - "note": "traversal may be incomplete — see `ecp doctor` blind spots catalog", - }); - } - - if args.test_coverage { - let analyses = coverage_analyses( - graph, - view, - &per_match_bfs, - &args.direction, - args.depth, - min_conf, - effective_include_tests, - &rel_filter, - ); - result_obj["coverage"] = build_coverage_json(analyses); - } - - // FU-2026-05-29-011: with ≥2 same-named defs in the graph, the resolver - // suppressed every bare call to this name at index time - // (`DecisionTier::AmbiguousGlobal`), so the upstream caller set is a - // lower bound — the payload must say so instead of reading as complete. - let ambiguity_caveat = (same_name_defs >= 2 - && matches!(args.direction, Direction::Up | Direction::Both)) - .then(|| { - format!( - "caller set may be incomplete: {same_name_defs} same-named definitions of \ - '{bare_name}' exist, so bare calls (no import/qualifier context) were \ - ambiguity-suppressed at index time. Cross-check call sites with grep \ - before trusting the blast radius." - ) - }); - - Ok(( - result_obj, - ImpactHints { - empty_hint_name: emit_empty_hint.then(|| format_fqn(owner_filter, bare_name)), - empty_hint_is_field, - hidden_edges: hidden_edges_total, - hidden_heuristic_edges: hidden_heuristic_total, - ambiguity_caveat, - }, - )) -} - -fn impact_with_baseline(args: &ImpactArgs, engine: &Engine) -> Result { - let baseline_ref = args.baseline.as_deref().unwrap(); - let repo_path = PathBuf::from(args.repo.as_deref().unwrap_or(".")); - - let scope = DiffScope::Compare(baseline_ref.to_string()); - let provider = ShellGitProvider; - let file_diffs = provider.diff(&repo_path, &scope)?; - - // Un-filtered file list from `git diff`. Emitted in the JSON envelope as - // `changed_paths` so downstream consumers (pr-analyze, future area - // classifiers) can branch on docs-only / whitespace-only / comment-only - // diffs (which yield zero `changed_symbols`) without a second - // `git diff --name-only` subprocess. - let changed_paths: Vec = file_diffs.iter().map(|fd| fd.file_path.clone()).collect(); - - if file_diffs.is_empty() { - return Ok(json!({ - "status": "success", - "baseline": baseline_ref, - "message": "0 changes detected — no symbols to assess", - "changed_paths": changed_paths, - "changed_symbols": [], - "impact_by_symbol": [], - })); - } - - let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; - let view = engine.overlay_view(); - - // Test-filtered subset for the semantic re-parse + BFS lookup. The JSON - // envelope still emits the full `changed_paths` (above). - let parsed_paths: Vec = file_diffs - .iter() - .filter(|fd| args.include_tests || !is_test_path(&fd.file_path)) - .map(|fd| fd.file_path.clone()) - .collect(); - - // Re-parse new and old side per changed file. Each iteration is - // independent (writes only into its own local vectors), and tree-sitter - // parse + `git show` subprocess dominate the work — fan out via rayon - // and merge at the end. `pipeline.parse_file_raw` is the same call path - // that `pipeline.analyze`'s `into_par_iter` already uses, so providers - // are Send + Sync by construction. - // - // Scoped to the languages this diff actually touches (mirrors the - // incremental-reanalyze path) instead of `make_pipeline()`'s full - // 20-provider tree-sitter `Query` compile (~0.65s) — a 2-file diff was - // paying that fixed cost for ~8ms of real parse work. `provider_name_for_path` - // never routes a path to "Markdown"/"YAML" (no extension dispatch exists - // for either — confirmed dead in `make_pipeline()` too), so skipping them - // here changes nothing observable. - let needed: HashSet<&str> = parsed_paths - .iter() - .filter_map(|p| { - ecp_core::analyzer::pipeline::AnalyzerPipeline::provider_name_for_path( - std::path::Path::new(p), - ) - }) - .collect(); - let pipeline = make_pipeline_for_names(needed.iter().copied()); - type NewEntry = ((&'static str, String, String), (u64, u32)); - type OldEntry = ((&'static str, String, String), u64); - - let per_file: Vec<(Vec, Vec)> = parsed_paths - .par_iter() - .map(|rel_path| { - let mut new_local: Vec = Vec::new(); - let mut old_local: Vec = Vec::new(); - - let abs = repo_path.join(rel_path); - if abs.exists() { - if let Ok(src) = std::fs::read(&abs) { - let rel_pb = PathBuf::from(rel_path); - if let Ok(lg) = pipeline.parse_file_raw(&rel_pb, &src) { - let lines: Vec<&[u8]> = src.split(|&b| b == b'\n').collect(); - for raw in &lg.nodes { - if matches!(raw.kind, NodeKind::File | NodeKind::Process) { - continue; - } - let h = hash_node_lines(&lines, raw.span.0, raw.span.2); - let kind_str = node_kind_to_str(&raw.kind); - new_local.push(( - (kind_str, rel_path.clone(), raw.name.clone()), - (h, raw.span.0), - )); - } - } - } - } - - if let Some(old_src) = head_blob_at(&repo_path, rel_path, baseline_ref) { - let rel_pb = PathBuf::from(rel_path); - if let Ok(lg) = pipeline.parse_file_raw(&rel_pb, &old_src) { - let lines: Vec<&[u8]> = old_src.split(|&b| b == b'\n').collect(); - for raw in &lg.nodes { - if matches!(raw.kind, NodeKind::File | NodeKind::Process) { - continue; - } - let h = hash_node_lines(&lines, raw.span.0, raw.span.2); - let kind_str = node_kind_to_str(&raw.kind); - old_local.push(((kind_str, rel_path.clone(), raw.name.clone()), h)); - } - } - } - - (new_local, old_local) - }) - .collect(); - - let total_new = per_file.iter().map(|(n, _)| n.len()).sum(); - let total_old = per_file.iter().map(|(_, o)| o.len()).sum(); - let mut new_map: HashMap<(&'static str, String, String), (u64, u32)> = - HashMap::with_capacity(total_new); - let mut old_map: HashMap<(&'static str, String, String), u64> = - HashMap::with_capacity(total_old); - for (new_local, old_local) in per_file { - new_map.extend(new_local); - old_map.extend(old_local); - } - - // Build lookup from old graph: (kind_str, file_path, name) → node_idx. - let parsed_paths_set: HashSet<&str> = parsed_paths.iter().map(|s| s.as_str()).collect(); - let mut old_graph_idx: HashMap<(&'static str, String, String), usize> = HashMap::new(); - for (idx, node) in graph.nodes.iter().enumerate() { - // Synthetic nodes (decorates_edges resolver-miss `Annotation`) carry - // `file_idx == SYNTHETIC_FILE_IDX` (u32::MAX). Skip — they don't - // belong to any file in `parsed_paths_set` by construction. - if !node.has_owning_file() { - continue; - } - let file_node = &graph.files[node.file_idx.to_native() as usize]; - let file_path = file_node.path.resolve(&graph.string_pool); - if !parsed_paths_set.contains(file_path) { - continue; - } - let kind_str = kind_to_str(&node.kind); - let name = node.name.resolve(&graph.string_pool).to_string(); - old_graph_idx.insert((kind_str, file_path.to_string(), name), idx); - } - - // Collect changed symbol keys + their graph indices. - let mut changed_symbols: Vec = Vec::new(); - let mut changed_node_indices: Vec = Vec::new(); - - for (key, (_, start_row)) in &new_map { - if !old_map.contains_key(key) { - changed_symbols.push(json!({ - "name": key.2, - "kind": key.0, - "filePath": key.1, - "line": start_row, - "change_type": "added", - })); - if let Some(&idx) = old_graph_idx.get(key) { - if !changed_node_indices.contains(&idx) { - changed_node_indices.push(idx); - } - } - } - } - - for (key, old_hash) in &old_map { - match new_map.get(key) { - Some((new_hash, start_row)) => { - if old_hash != new_hash { - changed_symbols.push(json!({ - "name": key.2, - "kind": key.0, - "filePath": key.1, - "line": start_row, - "change_type": "modified", - })); - if let Some(&idx) = old_graph_idx.get(key) { - if !changed_node_indices.contains(&idx) { - changed_node_indices.push(idx); - } - } - } - } - None => { - changed_symbols.push(json!({ - "name": key.2, - "kind": key.0, - "filePath": key.1, - "line": 0u32, - "change_type": "removed", - })); - if let Some(&idx) = old_graph_idx.get(key) { - if !changed_node_indices.contains(&idx) { - changed_node_indices.push(idx); - } - } - } - } - } - - let min_conf = resolve_min_conf(args); - let rel_filter = parse_csv_lower(args.relation_types.as_deref()); - // --test-coverage implies --include-tests so test callers are reachable. - let effective_include_tests = args.include_tests || args.test_coverage; - - // Run BFS from each changed symbol. - let mut impact_by_symbol: Vec = Vec::new(); - let mut hidden_edges_total: u64 = 0; - let mut hidden_heuristic_total: u64 = 0; - let mut per_symbol_bfs: Vec<(usize, Vec)> = Vec::new(); - for &base_idx in &changed_node_indices { - let node = &graph.nodes[base_idx]; - if !node.has_owning_file() { - continue; - } - // Merged-space entry: a changed symbol further edited in the working - // tree starts at its virtual twin; one deleted on disk is skipped - // (no node to traverse from). - let start_idx = match view { - Some(v) => match v.redirect(base_idx as u32) { - Some(t) => t as usize, - None => continue, - }, - None => base_idx, - }; - let meta = merged_node_meta(graph, view, start_idx); - let (sym_name, sym_file) = (meta.name, meta.file_path); - let (det_results, heur_results, hidden_conf, hidden_heur) = run_bfs( - graph, - view, - start_idx, - &args.direction, - args.depth, - min_conf, - effective_include_tests, - &rel_filter, - !args.no_heuristic, - ); - let mut sym_entry = json!({ - "symbol": sym_name, - "filePath": sym_file, - "impact": det_results.clone(), - }); - if !args.no_heuristic { - sym_entry["heuristic_callers"] = json!(tag_heuristic(heur_results)); - } - // Orphan-symbol fallback: when upstream-only mode finds no callers, - // attach depth-1 downstream callees so the changed symbol still - // exposes structural signal (its callees) instead of an empty - // `impact: []`. `det_results.len() <= 1` relies on the documented - // `run_bfs` start-node-at-depth-0 invariant. - if args.direction == Direction::Up && det_results.len() <= 1 { - let (downstream_results, _, _, _) = run_bfs( - graph, - view, - start_idx, - &Direction::Down, - 1, // depth = 1, direct callees only - min_conf, - effective_include_tests, - &rel_filter, - !args.no_heuristic, - ); - if downstream_results.len() > 1 { - sym_entry["downstream_callees"] = json!(downstream_results); - } - } - impact_by_symbol.push(sym_entry); - hidden_edges_total += hidden_conf; - hidden_heuristic_total += hidden_heur; - per_symbol_bfs.push((start_idx, det_results)); - } - - let mut result = json!({ - "status": "success", - "baseline": baseline_ref, - "changed_paths": changed_paths, - "changed_symbols": changed_symbols, - "impact_by_symbol": impact_by_symbol, - }); - attach_hidden_edges(&mut result, hidden_edges_total); - attach_heuristic_fields( - &mut result, - hidden_heuristic_total, - vec![], - !args.no_heuristic, - args.explain_confidence, - args.confidence_threshold, - ); - - if args.test_coverage { - let analyses = coverage_analyses( - graph, - view, - &per_symbol_bfs, - &args.direction, - args.depth, - min_conf, - effective_include_tests, - &rel_filter, - ); - result["coverage"] = build_coverage_json(analyses); - } - - Ok(result) -} - -/// Attach the hidden-edge count to the JSON result when filtering actually -/// dropped something. Skipping the field when N=0 keeps default invocations -/// noise-free and lets callers branch on `result.get("hidden_edges")`. -fn attach_hidden_edges(result: &mut Value, hidden_edges: u64) { - if hidden_edges > 0 { - result["hidden_edges"] = json!(hidden_edges); - } -} - -/// Attach heuristic-filter fields to the JSON result object. -/// -/// `hidden_heuristic_edges` is always written (0 is safe — callers can branch -/// on the field existing). `heuristic_callers` section is always present when -/// `include_heuristic` is true (empty array allowed), with each entry tagged -/// `requires_verification: true` so consumers never mistake a heuristic lead -/// for a deterministic caller. -/// `explain_confidence` block is appended when the flag is set. -fn attach_heuristic_fields( - result: &mut Value, - hidden_heuristic_edges: u64, - heuristic_results: Vec, - include_heuristic: bool, - explain_confidence: bool, - confidence_threshold: f32, -) { - result["hidden_heuristic_edges"] = json!(hidden_heuristic_edges); - let heuristic_reached = heuristic_results.len() as u64; - if include_heuristic { - result["heuristic_callers"] = json!(tag_heuristic(heuristic_results)); - } - if explain_confidence { - result["explain_confidence"] = json!({ - "threshold": confidence_threshold, - "edges_filtered_by_tier": { - "unknown_tier": heuristic_reached + hidden_heuristic_edges, - }, - }); - } -} - -/// Tag each heuristic caller `requires_verification: true` so a consumer never -/// mistakes a ~0.85 heuristic lead for a 1.0 deterministic caller. -fn tag_heuristic(entries: Vec) -> Vec { - entries - .into_iter() - .map(|mut e| { - if let Some(obj) = e.as_object_mut() { - obj.insert("requires_verification".to_string(), json!(true)); - } - e - }) - .collect() -} - -/// Stderr footer mirroring `attach_hidden_edges` — emitted only when the -/// trust filter dropped at least one edge, routed to stderr so it doesn't -/// corrupt machine-readable JSON/TOON on stdout. -fn emit_hidden_edges_footer(hidden_edges: u64) { - if hidden_edges > 0 { - eprintln!( - "note: {hidden_edges} edges hidden by trust filter (drop --high-trust-only / --min-confidence to see all)" - ); - } -} - -/// Resolve the effective confidence threshold from `--min-confidence` / -/// `--high-trust-only` / repo config. -fn resolve_min_conf(args: &ImpactArgs) -> f32 { - let repo_root = args - .repo - .as_ref() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - let cfg_threshold = config::load(&repo_root) - .map(|c| c.confidence.high_trust_threshold) - .unwrap_or(HIGH_TRUST_CONFIDENCE); - args.min_confidence.unwrap_or(if args.high_trust_only { - cfg_threshold - } else { - 0.0 - }) -} - -fn direction_str(dir: &Direction) -> &'static str { - match dir { - Direction::Up => "upstream", - Direction::Down => "downstream", - Direction::Both => "both", - } -} - -/// Display metadata for a merged-space node index (`>= graph.nodes.len()` = -/// overlay virtual node). Cold-path companion to `run_bfs`'s inline emission -/// (which keeps its per-file test-path cache). -struct MergedNodeMeta { - uid: u64, - name: String, - kind: &'static str, - file_path: String, - line: u32, -} - -fn merged_node_meta( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - view: Option<&OverlayView>, - idx: usize, -) -> MergedNodeMeta { - if let Some(vn) = view.and_then(|v| v.node(idx as u32)) { - return MergedNodeMeta { - uid: vn.uid, - name: vn.name.clone(), - kind: node_kind_to_str(&vn.kind), - file_path: vn.rel_path.to_string(), - line: vn.start_line, - }; - } - let node = &graph.nodes[idx]; - MergedNodeMeta { - uid: node.uid.to_native(), - name: node.name.resolve(&graph.string_pool).to_string(), - kind: kind_to_str(&node.kind), - file_path: graph.files[node.file_idx.to_native() as usize] - .path - .resolve(&graph.string_pool) - .to_string(), - line: node.start_line(), - } -} - -/// One edge under merged traversal: an archived base-graph edge or an -/// overlay-resolved [`ViewEdge`]. Unifies the filter chain (confidence, -/// containment, heuristic, `--relation-types`) so base and overlay edges -/// can never drift on traversal policy. -enum MergedEdgeRef<'a> { - Base(&'a ecp_core::graph::ArchivedEdge), - Overlay(&'a ViewEdge), -} - -impl MergedEdgeRef<'_> { - fn confidence(&self) -> f32 { - match self { - Self::Base(e) => e.confidence.to_native(), - Self::Overlay(e) => e.confidence, - } - } - - fn rel_type(&self) -> RelType { - match self { - Self::Base(e) => RelType::from(&e.rel_type), - Self::Overlay(e) => e.rel_type, - } - } - - fn rel_str(&self) -> &'static str { - match self { - Self::Base(e) => rel_to_str(&e.rel_type), - Self::Overlay(e) => { - debug_assert!( - matches!(e.rel_type, RelType::Calls), - "extend rel_str when the overlay gains new edge kinds" - ); - "calls" - } - } - } - - /// `viaReason` for the BFS payload. Overlay edges carry a static marker - /// so consumers can tell a caller comes from an uncommitted edit. - fn reason(&self, graph: &ecp_core::graph::ArchivedZeroCopyGraph) -> String { - match self { - Self::Base(e) => e.reason.resolve(&graph.string_pool).to_string(), - Self::Overlay(_) => "l1-overlay".to_string(), - } - } -} - -/// Core BFS over the merged graph (base CSR + optional overlay view) from -/// `start_idx`, which is a MERGED-space index: `< graph.nodes.len()` = base -/// node, above = overlay virtual node. -/// -/// Returns `(det_results, heur_results, hidden_conf_edges, hidden_heuristic_edges)`. -/// The start node appears at depth 0 in `det_results`. -/// -/// - `det_results`: nodes reached exclusively via deterministic edges. -/// - `heur_results`: nodes reached via a heuristic edge (only populated when -/// `include_heuristic` is true; kept in a separate vec so callers render them -/// in a distinct output section per the T-H1 spec). -/// - `hidden_conf_edges`: edges dropped because confidence < `min_conf`. -/// - `hidden_heuristic_edges`: heuristic edges skipped when `include_heuristic` -/// is false. These are the structural signal surfaced as -/// `hidden_heuristic_edges: N` in the output payload. -/// -/// `--include-tests` / `--relation-types` / `min_conf` are applied here; -/// `--kind` / `--file` emission-only filtering is NOT applied here. -/// -/// With a view, the masking invariant (mask ⊆ rebuild) governs base edges: -/// sourced-in-dirty-file edges are masked only for rels the overlay -/// re-resolves (`Calls` today — overlay adjacency is that file's truth); -/// other rels keep their base edges. Either endpoint in a dirty file is -/// redirected (replaced) into merged space or dropped (suppressed). -/// -/// **Invariant:** the deterministic result vec always begins with the start -/// node itself at `depth = 0` (so `len() == 1` means "no neighbours reached"). -/// Callers relying on this for orphan-detection (see `impact_with_baseline`'s -/// downstream fallback) MUST be updated if this invariant changes. -#[allow(clippy::too_many_arguments)] -fn run_bfs( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - view: Option<&OverlayView>, - start_idx: usize, - direction: &Direction, - max_depth: usize, - min_conf: f32, - include_tests: bool, - rel_filter: &Option>, - include_heuristic: bool, -) -> (Vec, Vec, u64, u64) { - // (node_idx, depth, via_edge_info, reached_via_heuristic) - type ViaEdge = Option<(String, f32)>; - type Step = (usize, usize, ViaEdge, bool); - - let base_len = graph.nodes.len(); - let mut visited = HashSet::new(); - let mut queue: VecDeque = VecDeque::new(); - let mut det_results: Vec = Vec::new(); - let mut heur_results: Vec = Vec::new(); - let mut test_path_cache = HashMap::new(); - let mut hidden_conf_edges: u64 = 0; - let mut hidden_heuristic_edges: u64 = 0; - - queue.push_back((start_idx, 0, None, false)); - visited.insert(start_idx); - - while let Some((curr_idx, curr_depth, via, via_heuristic)) = queue.pop_front() { - // ── node emission (merged space: < base_len = base, else virtual) ── - let (uid, name, owner_class, kind_str, file_path, line): ( - u64, - String, - Option, - &'static str, - String, - u32, - ); - if curr_idx < base_len { - let curr_node = &graph.nodes[curr_idx]; - // BFS via `Decorates` edges can reach synthetic Annotation nodes - // (SYNTHETIC_FILE_IDX); they have no file:line to report. - if !curr_node.has_owning_file() { - continue; - } - let file_idx = curr_node.file_idx.to_native() as usize; - if !include_tests { - let is_test = *test_path_cache.entry(file_idx).or_insert_with(|| { - let file_path = graph.files[file_idx].path.resolve(&graph.string_pool); - is_test_path(file_path) - }); - if is_test { - continue; - } - } - uid = curr_node.uid.to_native(); - name = curr_node.name.resolve(&graph.string_pool).to_string(); - owner_class = resolve_owner_class(graph, curr_idx).map(str::to_owned); - kind_str = kind_to_str(&curr_node.kind); - file_path = graph.files[file_idx] - .path - .resolve(&graph.string_pool) - .to_string(); - line = curr_node.start_line(); - } else { - // No has_owning_file guard here: virtual nodes come from freshly - // parsed source files, never from synthetic emission. - let vn = view - .and_then(|v| v.node(curr_idx as u32)) - .expect("virtual index enqueued without a view"); - if !include_tests && is_test_path(&vn.rel_path) { - continue; - } - uid = vn.uid; - name = vn.name.clone(); - owner_class = vn.owner_class.clone(); - kind_str = node_kind_to_str(&vn.kind); - file_path = vn.rel_path.to_string(); - line = vn.start_line; - } - - let (via_reason, via_confidence) = via - .as_ref() - .map(|(r, c)| (r.as_str(), *c)) - .unwrap_or(("", 1.0)); - let entry = json!({ - "uid": uid.to_string(), - "name": name, - "ownerClass": owner_class, - "kind": kind_str, - "filePath": file_path, - "line": line, - "depth": curr_depth, - "viaReason": via_reason, - "viaConfidence": via_confidence, - }); - if via_heuristic { - heur_results.push(entry); - } else { - det_results.push(entry); - } - - if curr_depth >= max_depth { - continue; - } - - // ── expansion ─────────────────────────────────────────────────── - // Shared filter chain + enqueue for base and overlay edges. - let mut consider = |edge: MergedEdgeRef<'_>, next_idx: usize| { - let edge_conf = edge.confidence(); - if edge_conf < min_conf { - hidden_conf_edges += 1; - return; - } - let rel = edge.rel_type(); - // Structural containment edges (Defines, HasMethod, HasProperty, - // Imports) describe where a symbol lives, not who calls it. - // Exclude from BFS so File→Function Defines does not register - // as a caller. - if rel.is_scope_containment() { - return; - } - let is_heur = rel.is_heuristic(); - if is_heur && !include_heuristic { - hidden_heuristic_edges += 1; - return; - } - if let Some(rels) = rel_filter.as_ref() { - let rel_str = edge.rel_str(); - if !rels.iter().any(|r| r == rel_str) { - return; - } - } - if !visited.contains(&next_idx) { - visited.insert(next_idx); - queue.push_back(( - next_idx, - curr_depth + 1, - Some((edge.reason(graph), edge_conf)), - is_heur, - )); - } - }; - - if matches!(direction, Direction::Up | Direction::Both) { - // Base IN-edges anchor: the node itself when base; the replaced - // base twin when virtual (clean-file callers still point at it); - // None for a brand-new virtual symbol — no base edge can target - // it, so only the overlay reverse index below applies. - let in_anchor = if curr_idx < base_len { - Some(curr_idx) - } else { - view.and_then(|v| v.node(curr_idx as u32)) - .and_then(|n| n.replaced_base) - .map(|b| b as usize) - }; - if let Some(anchor) = in_anchor { - let in_start = graph.in_offsets[anchor].to_native() as usize; - let in_end = graph.in_offsets[anchor + 1].to_native() as usize; - for i in in_start..in_end { - let edge_idx = graph.in_edge_idx[i].to_native() as usize; - let edge = &graph.edges[edge_idx]; - let src = edge.source.to_native() as usize; - // mask ⊆ rebuild: drop a dirty-file source's edge only - // for rels the overlay re-resolves (its truth is in - // overlay_in below); other rels keep the base edge with - // the source redirected into merged space. - let next_idx = match view { - Some(v) => { - if v.masks_base_edge(src as u32, RelType::from(&edge.rel_type)) { - continue; - } - match v.redirect(src as u32) { - Some(s) => s as usize, - None => continue, // source deleted on disk - } - } - None => src, - }; - consider(MergedEdgeRef::Base(edge), next_idx); - } - } - if let Some(v) = view { - for (_, e) in v.overlay_in(curr_idx as u32) { - consider(MergedEdgeRef::Overlay(e), e.source as usize); - } - } - } - - if matches!(direction, Direction::Down | Direction::Both) { - // Base OUT-edges anchor mirrors in_anchor: a replaced virtual - // node keeps its base twin's edges for rels the overlay can't - // rebuild (masks_base_edge filters the rebuilt ones). - let out_anchor = if curr_idx < base_len { - Some(curr_idx) - } else { - view.and_then(|v| v.node(curr_idx as u32)) - .and_then(|n| n.replaced_base) - .map(|b| b as usize) - }; - if let Some(anchor) = out_anchor { - let out_start = graph.out_offsets[anchor].to_native() as usize; - let out_end = graph.out_offsets[anchor + 1].to_native() as usize; - for i in out_start..out_end { - let edge = &graph.edges[i]; - let target = edge.target.to_native() as usize; - let next_idx = match view { - Some(v) => { - if v.masks_base_edge(anchor as u32, RelType::from(&edge.rel_type)) { - continue; - } - match v.redirect(target as u32) { - Some(t) => t as usize, - None => continue, // target deleted on disk - } - } - None => target, - }; - consider(MergedEdgeRef::Base(edge), next_idx); - } - } - if let Some(v) = view { - for (_, e) in v.overlay_out(curr_idx as u32) { - consider(MergedEdgeRef::Overlay(e), e.target as usize); - } - } - } - } - - ( - det_results, - heur_results, - hidden_conf_edges, - hidden_heuristic_edges, - ) -} - -fn collect_blind_spots( - graph: &ecp_core::graph::ArchivedZeroCopyGraph, - target_file_path: &str, -) -> Vec { - graph - .blind_spots - .iter() - .filter(|bs| bs.file_path.resolve(&graph.string_pool) == target_file_path) - .map(|bs| bs.kind.resolve(&graph.string_pool).to_string()) - .collect() -} - -/// FNV-64 hash of the source lines spanning [start_row, end_row] (inclusive, -/// 0-based). Normalises trailing whitespace so indent-only edits are stable. -fn hash_node_lines(lines: &[&[u8]], start_row: u32, end_row: u32) -> u64 { - const FNV_OFFSET: u64 = 14_695_981_039_346_656_037; - const FNV_PRIME: u64 = 1_099_511_628_211; - - let start = start_row as usize; - let end = (end_row as usize).min(lines.len().saturating_sub(1)); - if start > end || start >= lines.len() { - return 0; - } - - let mut hash = FNV_OFFSET; - for &line in &lines[start..=end] { - let trimmed = line - .iter() - .rposition(|&b| b != b' ' && b != b'\t' && b != b'\r') - .map(|pos| &line[..=pos]) - .unwrap_or(b""); - for &byte in trimmed { - hash ^= byte as u64; - hash = hash.wrapping_mul(FNV_PRIME); - } - hash ^= b'\n' as u64; - hash = hash.wrapping_mul(FNV_PRIME); - } - hash -} - -/// Fetch the content of a repo-relative path at a specific git ref via -/// `git show :`. Returns `None` for paths not present at that ref. -fn head_blob_at(repo: &std::path::Path, rel_path: &str, git_ref: &str) -> Option> { - use crate::git::safe_exec; - let out = safe_exec::git() - .args(["show", &format!("{git_ref}:{rel_path}")]) - .current_dir(repo) - .output() - .ok()?; - if out.status.success() { - Some(out.stdout) - } else { - None - } -} - -#[cfg(test)] -mod literal_coherence_tests { - use super::*; - - fn group(value: &str, file: &str, sink_reason: &str) -> LiteralGroup { - LiteralGroup { - value: value.to_string(), - sites: vec![LiteralSite { - file: file.to_string(), - line: 1, - col: 0, - enclosing: Some("f".to_string()), - sink_reason: sink_reason.to_string(), - }], - } - } - - #[test] - fn coherence_finds_session_meta_split_brain_pair() { - let groups = vec![ - group( - "meta.json", - "src/session/read.rs", - "sink:read|confidence:high", - ), - group( - "session_meta.json", - "src/session/write.rs", - "sink:write|confidence:high", - ), - ]; - - let candidates = literal_coherence_candidates(&groups); - - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0]["reader_literal"], "meta.json"); - assert_eq!(candidates[0]["writer_literal"], "session_meta.json"); - } - - #[test] - fn coherence_rejects_same_access_pairs() { - let groups = vec![ - group("config.yml", "src/config/a.rs", "sink:read|confidence:high"), - group( - "configs.yml", - "src/config/b.rs", - "sink:read|confidence:high", - ), - ]; - - assert!(literal_coherence_candidates(&groups).is_empty()); - } - - #[test] - fn coherence_rejects_different_extensions() { - let groups = vec![ - group("Cargo.toml", "src/build/a.rs", "sink:read|confidence:high"), - group("Cargo.lock", "src/build/b.rs", "sink:write|confidence:high"), - ]; - - assert!(literal_coherence_candidates(&groups).is_empty()); - } - - #[test] - fn coherence_rejects_unknown_access_mixed_with_read() { - let mut reader = group( - "meta.json", - "src/session/read.rs", - "sink:read|confidence:high", - ); - reader.sites.push(LiteralSite { - file: "src/session/path.rs".to_string(), - line: 2, - col: 0, - enclosing: Some("path".to_string()), - sink_reason: "sink:join|confidence:medium".to_string(), - }); - let groups = vec![ - reader, - group( - "session_meta.json", - "src/session/write.rs", - "sink:write|confidence:high", - ), - ]; - - assert!(literal_coherence_candidates(&groups).is_empty()); - } -} diff --git a/crates/ecp-cli/src/commands/impact/baseline.rs b/crates/ecp-cli/src/commands/impact/baseline.rs new file mode 100644 index 00000000..41625543 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/baseline.rs @@ -0,0 +1,363 @@ +use super::bfs::{merged_node_meta, run_bfs}; +use super::coverage::{build_coverage_json, coverage_analyses}; +use super::{parse_csv_lower, resolve_min_conf, tag_heuristic, ImpactArgs}; +use crate::commands::format::{kind_to_str, node_kind_to_str}; +use crate::commands::impact::{attach_heuristic_fields, attach_hidden_edges, Direction}; +use crate::engine::Engine; +use crate::git::{DiffScope, GitDiffProvider, ShellGitProvider}; +use crate::reanalyze::make_pipeline_for_names; +use ecp_core::algorithms::process_trace::is_test_path; +use ecp_core::graph::NodeKind; +use ecp_core::EcpError; +use rayon::prelude::*; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +pub(super) fn impact_with_baseline(args: &ImpactArgs, engine: &Engine) -> Result { + let baseline_ref = args.baseline.as_deref().unwrap(); + let repo_path = PathBuf::from(args.repo.as_deref().unwrap_or(".")); + + let scope = DiffScope::Compare(baseline_ref.to_string()); + let provider = ShellGitProvider; + let file_diffs = provider.diff(&repo_path, &scope)?; + + // Un-filtered file list from `git diff`. Emitted in the JSON envelope as + // `changed_paths` so downstream consumers (pr-analyze, future area + // classifiers) can branch on docs-only / whitespace-only / comment-only + // diffs (which yield zero `changed_symbols`) without a second + // `git diff --name-only` subprocess. + let changed_paths: Vec = file_diffs.iter().map(|fd| fd.file_path.clone()).collect(); + + if file_diffs.is_empty() { + return Ok(json!({ + "status": "success", + "baseline": baseline_ref, + "message": "0 changes detected — no symbols to assess", + "changed_paths": changed_paths, + "changed_symbols": [], + "impact_by_symbol": [], + })); + } + + let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; + let view = engine.overlay_view(); + + // Test-filtered subset for the semantic re-parse + BFS lookup. The JSON + // envelope still emits the full `changed_paths` (above). + let parsed_paths: Vec = file_diffs + .iter() + .filter(|fd| args.include_tests || !is_test_path(&fd.file_path)) + .map(|fd| fd.file_path.clone()) + .collect(); + + // Re-parse new and old side per changed file. Each iteration is + // independent (writes only into its own local vectors), and tree-sitter + // parse + `git show` subprocess dominate the work — fan out via rayon + // and merge at the end. `pipeline.parse_file_raw` is the same call path + // that `pipeline.analyze`'s `into_par_iter` already uses, so providers + // are Send + Sync by construction. + // + // Scoped to the languages this diff actually touches (mirrors the + // incremental-reanalyze path) instead of `make_pipeline()`'s full + // 20-provider tree-sitter `Query` compile (~0.65s) — a 2-file diff was + // paying that fixed cost for ~8ms of real parse work. `provider_name_for_path` + // never routes a path to "Markdown"/"YAML" (no extension dispatch exists + // for either — confirmed dead in `make_pipeline()` too), so skipping them + // here changes nothing observable. + let needed: HashSet<&str> = parsed_paths + .iter() + .filter_map(|p| { + ecp_core::analyzer::pipeline::AnalyzerPipeline::provider_name_for_path( + std::path::Path::new(p), + ) + }) + .collect(); + let pipeline = make_pipeline_for_names(needed.iter().copied()); + type NewEntry = ((&'static str, String, String), (u64, u32)); + type OldEntry = ((&'static str, String, String), u64); + + let per_file: Vec<(Vec, Vec)> = parsed_paths + .par_iter() + .map(|rel_path| { + let mut new_local: Vec = Vec::new(); + let mut old_local: Vec = Vec::new(); + + let abs = repo_path.join(rel_path); + if abs.exists() { + if let Ok(src) = std::fs::read(&abs) { + let rel_pb = PathBuf::from(rel_path); + if let Ok(lg) = pipeline.parse_file_raw(&rel_pb, &src) { + let lines: Vec<&[u8]> = src.split(|&b| b == b'\n').collect(); + for raw in &lg.nodes { + if matches!(raw.kind, NodeKind::File | NodeKind::Process) { + continue; + } + let h = hash_node_lines(&lines, raw.span.0, raw.span.2); + let kind_str = node_kind_to_str(&raw.kind); + new_local.push(( + (kind_str, rel_path.clone(), raw.name.clone()), + (h, raw.span.0), + )); + } + } + } + } + + if let Some(old_src) = head_blob_at(&repo_path, rel_path, baseline_ref) { + let rel_pb = PathBuf::from(rel_path); + if let Ok(lg) = pipeline.parse_file_raw(&rel_pb, &old_src) { + let lines: Vec<&[u8]> = old_src.split(|&b| b == b'\n').collect(); + for raw in &lg.nodes { + if matches!(raw.kind, NodeKind::File | NodeKind::Process) { + continue; + } + let h = hash_node_lines(&lines, raw.span.0, raw.span.2); + let kind_str = node_kind_to_str(&raw.kind); + old_local.push(((kind_str, rel_path.clone(), raw.name.clone()), h)); + } + } + } + + (new_local, old_local) + }) + .collect(); + + let total_new = per_file.iter().map(|(n, _)| n.len()).sum(); + let total_old = per_file.iter().map(|(_, o)| o.len()).sum(); + let mut new_map: HashMap<(&'static str, String, String), (u64, u32)> = + HashMap::with_capacity(total_new); + let mut old_map: HashMap<(&'static str, String, String), u64> = + HashMap::with_capacity(total_old); + for (new_local, old_local) in per_file { + new_map.extend(new_local); + old_map.extend(old_local); + } + + // Build lookup from old graph: (kind_str, file_path, name) → node_idx. + let parsed_paths_set: HashSet<&str> = parsed_paths.iter().map(|s| s.as_str()).collect(); + let mut old_graph_idx: HashMap<(&'static str, String, String), usize> = HashMap::new(); + for (idx, node) in graph.nodes.iter().enumerate() { + // Synthetic nodes (decorates_edges resolver-miss `Annotation`) carry + // `file_idx == SYNTHETIC_FILE_IDX` (u32::MAX). Skip — they don't + // belong to any file in `parsed_paths_set` by construction. + if !node.has_owning_file() { + continue; + } + let file_node = &graph.files[node.file_idx.to_native() as usize]; + let file_path = file_node.path.resolve(&graph.string_pool); + if !parsed_paths_set.contains(file_path) { + continue; + } + let kind_str = kind_to_str(&node.kind); + let name = node.name.resolve(&graph.string_pool).to_string(); + old_graph_idx.insert((kind_str, file_path.to_string(), name), idx); + } + + // Collect changed symbol keys + their graph indices. + let mut changed_symbols: Vec = Vec::new(); + let mut changed_node_indices: Vec = Vec::new(); + + for (key, (_, start_row)) in &new_map { + if !old_map.contains_key(key) { + changed_symbols.push(json!({ + "name": key.2, + "kind": key.0, + "filePath": key.1, + "line": start_row, + "change_type": "added", + })); + if let Some(&idx) = old_graph_idx.get(key) { + if !changed_node_indices.contains(&idx) { + changed_node_indices.push(idx); + } + } + } + } + + for (key, old_hash) in &old_map { + match new_map.get(key) { + Some((new_hash, start_row)) => { + if old_hash != new_hash { + changed_symbols.push(json!({ + "name": key.2, + "kind": key.0, + "filePath": key.1, + "line": start_row, + "change_type": "modified", + })); + if let Some(&idx) = old_graph_idx.get(key) { + if !changed_node_indices.contains(&idx) { + changed_node_indices.push(idx); + } + } + } + } + None => { + changed_symbols.push(json!({ + "name": key.2, + "kind": key.0, + "filePath": key.1, + "line": 0u32, + "change_type": "removed", + })); + if let Some(&idx) = old_graph_idx.get(key) { + if !changed_node_indices.contains(&idx) { + changed_node_indices.push(idx); + } + } + } + } + } + + let min_conf = resolve_min_conf(args); + let rel_filter = parse_csv_lower(args.relation_types.as_deref()); + // --test-coverage implies --include-tests so test callers are reachable. + let effective_include_tests = args.include_tests || args.test_coverage; + + // Run BFS from each changed symbol. + let mut impact_by_symbol: Vec = Vec::new(); + let mut hidden_edges_total: u64 = 0; + let mut hidden_heuristic_total: u64 = 0; + let mut per_symbol_bfs: Vec<(usize, Vec)> = Vec::new(); + for &base_idx in &changed_node_indices { + let node = &graph.nodes[base_idx]; + if !node.has_owning_file() { + continue; + } + // Merged-space entry: a changed symbol further edited in the working + // tree starts at its virtual twin; one deleted on disk is skipped + // (no node to traverse from). + let start_idx = match view { + Some(v) => match v.redirect(base_idx as u32) { + Some(t) => t as usize, + None => continue, + }, + None => base_idx, + }; + let meta = merged_node_meta(graph, view, start_idx); + let (sym_name, sym_file) = (meta.name, meta.file_path); + let (det_results, heur_results, hidden_conf, hidden_heur) = run_bfs( + graph, + view, + start_idx, + &args.direction, + args.depth, + min_conf, + effective_include_tests, + &rel_filter, + !args.no_heuristic, + ); + let mut sym_entry = json!({ + "symbol": sym_name, + "filePath": sym_file, + "impact": det_results.clone(), + }); + if !args.no_heuristic { + sym_entry["heuristic_callers"] = json!(tag_heuristic(heur_results)); + } + // Orphan-symbol fallback: when upstream-only mode finds no callers, + // attach depth-1 downstream callees so the changed symbol still + // exposes structural signal (its callees) instead of an empty + // `impact: []`. `det_results.len() <= 1` relies on the documented + // `run_bfs` start-node-at-depth-0 invariant. + if args.direction == Direction::Up && det_results.len() <= 1 { + let (downstream_results, _, _, _) = run_bfs( + graph, + view, + start_idx, + &Direction::Down, + 1, // depth = 1, direct callees only + min_conf, + effective_include_tests, + &rel_filter, + !args.no_heuristic, + ); + if downstream_results.len() > 1 { + sym_entry["downstream_callees"] = json!(downstream_results); + } + } + impact_by_symbol.push(sym_entry); + hidden_edges_total += hidden_conf; + hidden_heuristic_total += hidden_heur; + per_symbol_bfs.push((start_idx, det_results)); + } + + let mut result = json!({ + "status": "success", + "baseline": baseline_ref, + "changed_paths": changed_paths, + "changed_symbols": changed_symbols, + "impact_by_symbol": impact_by_symbol, + }); + attach_hidden_edges(&mut result, hidden_edges_total); + attach_heuristic_fields( + &mut result, + hidden_heuristic_total, + vec![], + !args.no_heuristic, + args.explain_confidence, + args.confidence_threshold, + ); + + if args.test_coverage { + let analyses = coverage_analyses( + graph, + view, + &per_symbol_bfs, + &args.direction, + args.depth, + min_conf, + effective_include_tests, + &rel_filter, + ); + result["coverage"] = build_coverage_json(analyses); + } + + Ok(result) +} + +/// FNV-64 hash of the source lines spanning [start_row, end_row] (inclusive, +/// 0-based). Normalises trailing whitespace so indent-only edits are stable. +fn hash_node_lines(lines: &[&[u8]], start_row: u32, end_row: u32) -> u64 { + const FNV_OFFSET: u64 = 14_695_981_039_346_656_037; + const FNV_PRIME: u64 = 1_099_511_628_211; + + let start = start_row as usize; + let end = (end_row as usize).min(lines.len().saturating_sub(1)); + if start > end || start >= lines.len() { + return 0; + } + + let mut hash = FNV_OFFSET; + for &line in &lines[start..=end] { + let trimmed = line + .iter() + .rposition(|&b| b != b' ' && b != b'\t' && b != b'\r') + .map(|pos| &line[..=pos]) + .unwrap_or(b""); + for &byte in trimmed { + hash ^= byte as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash ^= b'\n' as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +/// Fetch the content of a repo-relative path at a specific git ref via +/// `git show :`. Returns `None` for paths not present at that ref. +fn head_blob_at(repo: &std::path::Path, rel_path: &str, git_ref: &str) -> Option> { + use crate::git::safe_exec; + let out = safe_exec::git() + .args(["show", &format!("{git_ref}:{rel_path}")]) + .current_dir(repo) + .output() + .ok()?; + if out.status.success() { + Some(out.stdout) + } else { + None + } +} diff --git a/crates/ecp-cli/src/commands/impact/bfs.rs b/crates/ecp-cli/src/commands/impact/bfs.rs new file mode 100644 index 00000000..95b1c8e3 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/bfs.rs @@ -0,0 +1,359 @@ +use super::Direction; +use crate::commands::format::{kind_to_str, node_kind_to_str, rel_to_str}; +use crate::commands::symbol_id::resolve_owner_class; +use ecp_core::algorithms::process_trace::is_test_path; +use ecp_core::graph::RelType; +use ecp_core::session::{OverlayView, ViewEdge}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// Display metadata for a merged-space node index (`>= graph.nodes.len()` = +/// overlay virtual node). Cold-path companion to `run_bfs`'s inline emission +/// (which keeps its per-file test-path cache). +pub(super) struct MergedNodeMeta { + pub(super) uid: u64, + pub(super) name: String, + pub(super) kind: &'static str, + pub(super) file_path: String, + pub(super) line: u32, +} + +pub(super) fn merged_node_meta( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + view: Option<&OverlayView>, + idx: usize, +) -> MergedNodeMeta { + if let Some(vn) = view.and_then(|v| v.node(idx as u32)) { + return MergedNodeMeta { + uid: vn.uid, + name: vn.name.clone(), + kind: node_kind_to_str(&vn.kind), + file_path: vn.rel_path.to_string(), + line: vn.start_line, + }; + } + let node = &graph.nodes[idx]; + MergedNodeMeta { + uid: node.uid.to_native(), + name: node.name.resolve(&graph.string_pool).to_string(), + kind: kind_to_str(&node.kind), + file_path: graph.files[node.file_idx.to_native() as usize] + .path + .resolve(&graph.string_pool) + .to_string(), + line: node.start_line(), + } +} + +/// One edge under merged traversal: an archived base-graph edge or an +/// overlay-resolved [`ViewEdge`]. Unifies the filter chain (confidence, +/// containment, heuristic, `--relation-types`) so base and overlay edges +/// can never drift on traversal policy. +enum MergedEdgeRef<'a> { + Base(&'a ecp_core::graph::ArchivedEdge), + Overlay(&'a ViewEdge), +} + +impl MergedEdgeRef<'_> { + fn confidence(&self) -> f32 { + match self { + Self::Base(e) => e.confidence.to_native(), + Self::Overlay(e) => e.confidence, + } + } + + fn rel_type(&self) -> RelType { + match self { + Self::Base(e) => RelType::from(&e.rel_type), + Self::Overlay(e) => e.rel_type, + } + } + + fn rel_str(&self) -> &'static str { + match self { + Self::Base(e) => rel_to_str(&e.rel_type), + Self::Overlay(e) => { + debug_assert!( + matches!(e.rel_type, RelType::Calls), + "extend rel_str when the overlay gains new edge kinds" + ); + "calls" + } + } + } + + /// `viaReason` for the BFS payload. Overlay edges carry a static marker + /// so consumers can tell a caller comes from an uncommitted edit. + fn reason(&self, graph: &ecp_core::graph::ArchivedZeroCopyGraph) -> String { + match self { + Self::Base(e) => e.reason.resolve(&graph.string_pool).to_string(), + Self::Overlay(_) => "l1-overlay".to_string(), + } + } +} + +/// Core BFS over the merged graph (base CSR + optional overlay view) from +/// `start_idx`, which is a MERGED-space index: `< graph.nodes.len()` = base +/// node, above = overlay virtual node. +/// +/// Returns `(det_results, heur_results, hidden_conf_edges, hidden_heuristic_edges)`. +/// The start node appears at depth 0 in `det_results`. +/// +/// - `det_results`: nodes reached exclusively via deterministic edges. +/// - `heur_results`: nodes reached via a heuristic edge (only populated when +/// `include_heuristic` is true; kept in a separate vec so callers render them +/// in a distinct output section per the T-H1 spec). +/// - `hidden_conf_edges`: edges dropped because confidence < `min_conf`. +/// - `hidden_heuristic_edges`: heuristic edges skipped when `include_heuristic` +/// is false. These are the structural signal surfaced as +/// `hidden_heuristic_edges: N` in the output payload. +/// +/// `--include-tests` / `--relation-types` / `min_conf` are applied here; +/// `--kind` / `--file` emission-only filtering is NOT applied here. +/// +/// With a view, the masking invariant (mask ⊆ rebuild) governs base edges: +/// sourced-in-dirty-file edges are masked only for rels the overlay +/// re-resolves (`Calls` today — overlay adjacency is that file's truth); +/// other rels keep their base edges. Either endpoint in a dirty file is +/// redirected (replaced) into merged space or dropped (suppressed). +/// +/// **Invariant:** the deterministic result vec always begins with the start +/// node itself at `depth = 0` (so `len() == 1` means "no neighbours reached"). +/// Callers relying on this for orphan-detection (see `impact_with_baseline`'s +/// downstream fallback) MUST be updated if this invariant changes. +#[allow(clippy::too_many_arguments)] +pub(super) fn run_bfs( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + view: Option<&OverlayView>, + start_idx: usize, + direction: &Direction, + max_depth: usize, + min_conf: f32, + include_tests: bool, + rel_filter: &Option>, + include_heuristic: bool, +) -> (Vec, Vec, u64, u64) { + // (node_idx, depth, via_edge_info, reached_via_heuristic) + type ViaEdge = Option<(String, f32)>; + type Step = (usize, usize, ViaEdge, bool); + + let base_len = graph.nodes.len(); + let mut visited = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + let mut det_results: Vec = Vec::new(); + let mut heur_results: Vec = Vec::new(); + let mut test_path_cache = HashMap::new(); + let mut hidden_conf_edges: u64 = 0; + let mut hidden_heuristic_edges: u64 = 0; + + queue.push_back((start_idx, 0, None, false)); + visited.insert(start_idx); + + while let Some((curr_idx, curr_depth, via, via_heuristic)) = queue.pop_front() { + // ── node emission (merged space: < base_len = base, else virtual) ── + let (uid, name, owner_class, kind_str, file_path, line): ( + u64, + String, + Option, + &'static str, + String, + u32, + ); + if curr_idx < base_len { + let curr_node = &graph.nodes[curr_idx]; + // BFS via `Decorates` edges can reach synthetic Annotation nodes + // (SYNTHETIC_FILE_IDX); they have no file:line to report. + if !curr_node.has_owning_file() { + continue; + } + let file_idx = curr_node.file_idx.to_native() as usize; + if !include_tests { + let is_test = *test_path_cache.entry(file_idx).or_insert_with(|| { + let file_path = graph.files[file_idx].path.resolve(&graph.string_pool); + is_test_path(file_path) + }); + if is_test { + continue; + } + } + uid = curr_node.uid.to_native(); + name = curr_node.name.resolve(&graph.string_pool).to_string(); + owner_class = resolve_owner_class(graph, curr_idx).map(str::to_owned); + kind_str = kind_to_str(&curr_node.kind); + file_path = graph.files[file_idx] + .path + .resolve(&graph.string_pool) + .to_string(); + line = curr_node.start_line(); + } else { + // No has_owning_file guard here: virtual nodes come from freshly + // parsed source files, never from synthetic emission. + let vn = view + .and_then(|v| v.node(curr_idx as u32)) + .expect("virtual index enqueued without a view"); + if !include_tests && is_test_path(&vn.rel_path) { + continue; + } + uid = vn.uid; + name = vn.name.clone(); + owner_class = vn.owner_class.clone(); + kind_str = node_kind_to_str(&vn.kind); + file_path = vn.rel_path.to_string(); + line = vn.start_line; + } + + let (via_reason, via_confidence) = via + .as_ref() + .map(|(r, c)| (r.as_str(), *c)) + .unwrap_or(("", 1.0)); + let entry = json!({ + "uid": uid.to_string(), + "name": name, + "ownerClass": owner_class, + "kind": kind_str, + "filePath": file_path, + "line": line, + "depth": curr_depth, + "viaReason": via_reason, + "viaConfidence": via_confidence, + }); + if via_heuristic { + heur_results.push(entry); + } else { + det_results.push(entry); + } + + if curr_depth >= max_depth { + continue; + } + + // ── expansion ─────────────────────────────────────────────────── + // Shared filter chain + enqueue for base and overlay edges. + let mut consider = |edge: MergedEdgeRef<'_>, next_idx: usize| { + let edge_conf = edge.confidence(); + if edge_conf < min_conf { + hidden_conf_edges += 1; + return; + } + let rel = edge.rel_type(); + // Structural containment edges (Defines, HasMethod, HasProperty, + // Imports) describe where a symbol lives, not who calls it. + // Exclude from BFS so File→Function Defines does not register + // as a caller. + if rel.is_scope_containment() { + return; + } + let is_heur = rel.is_heuristic(); + if is_heur && !include_heuristic { + hidden_heuristic_edges += 1; + return; + } + if let Some(rels) = rel_filter.as_ref() { + let rel_str = edge.rel_str(); + if !rels.iter().any(|r| r == rel_str) { + return; + } + } + if !visited.contains(&next_idx) { + visited.insert(next_idx); + queue.push_back(( + next_idx, + curr_depth + 1, + Some((edge.reason(graph), edge_conf)), + is_heur, + )); + } + }; + + if matches!(direction, Direction::Up | Direction::Both) { + // Base IN-edges anchor: the node itself when base; the replaced + // base twin when virtual (clean-file callers still point at it); + // None for a brand-new virtual symbol — no base edge can target + // it, so only the overlay reverse index below applies. + let in_anchor = if curr_idx < base_len { + Some(curr_idx) + } else { + view.and_then(|v| v.node(curr_idx as u32)) + .and_then(|n| n.replaced_base) + .map(|b| b as usize) + }; + if let Some(anchor) = in_anchor { + let in_start = graph.in_offsets[anchor].to_native() as usize; + let in_end = graph.in_offsets[anchor + 1].to_native() as usize; + for i in in_start..in_end { + let edge_idx = graph.in_edge_idx[i].to_native() as usize; + let edge = &graph.edges[edge_idx]; + let src = edge.source.to_native() as usize; + // mask ⊆ rebuild: drop a dirty-file source's edge only + // for rels the overlay re-resolves (its truth is in + // overlay_in below); other rels keep the base edge with + // the source redirected into merged space. + let next_idx = match view { + Some(v) => { + if v.masks_base_edge(src as u32, RelType::from(&edge.rel_type)) { + continue; + } + match v.redirect(src as u32) { + Some(s) => s as usize, + None => continue, // source deleted on disk + } + } + None => src, + }; + consider(MergedEdgeRef::Base(edge), next_idx); + } + } + if let Some(v) = view { + for (_, e) in v.overlay_in(curr_idx as u32) { + consider(MergedEdgeRef::Overlay(e), e.source as usize); + } + } + } + + if matches!(direction, Direction::Down | Direction::Both) { + // Base OUT-edges anchor mirrors in_anchor: a replaced virtual + // node keeps its base twin's edges for rels the overlay can't + // rebuild (masks_base_edge filters the rebuilt ones). + let out_anchor = if curr_idx < base_len { + Some(curr_idx) + } else { + view.and_then(|v| v.node(curr_idx as u32)) + .and_then(|n| n.replaced_base) + .map(|b| b as usize) + }; + if let Some(anchor) = out_anchor { + let out_start = graph.out_offsets[anchor].to_native() as usize; + let out_end = graph.out_offsets[anchor + 1].to_native() as usize; + for i in out_start..out_end { + let edge = &graph.edges[i]; + let target = edge.target.to_native() as usize; + let next_idx = match view { + Some(v) => { + if v.masks_base_edge(anchor as u32, RelType::from(&edge.rel_type)) { + continue; + } + match v.redirect(target as u32) { + Some(t) => t as usize, + None => continue, // target deleted on disk + } + } + None => target, + }; + consider(MergedEdgeRef::Base(edge), next_idx); + } + } + if let Some(v) = view { + for (_, e) in v.overlay_out(curr_idx as u32) { + consider(MergedEdgeRef::Overlay(e), e.target as usize); + } + } + } + } + + ( + det_results, + heur_results, + hidden_conf_edges, + hidden_heuristic_edges, + ) +} diff --git a/crates/ecp-cli/src/commands/impact/coverage.rs b/crates/ecp-cli/src/commands/impact/coverage.rs new file mode 100644 index 00000000..08a6a027 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/coverage.rs @@ -0,0 +1,235 @@ +use super::bfs::{merged_node_meta, run_bfs}; +use super::Direction; +use ecp_core::session::OverlayView; +use serde_json::{json, Value}; + +// ── Test-coverage gap analysis ──────────────────────────────────────────────── + +/// Classification of a symbol's test coverage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CoverageClass { + /// Callers exist in prod but zero test callers. + Uncovered, + /// test_caller_count >= 1, but prod callers outnumber tests by > 3:1. + Partial, + /// test_caller_count >= 1 and prod:test ratio <= 3:1. + Covered, + /// No callers at all (entry-point / dead code path). + Orphan, +} + +/// Data collected for a single symbol during coverage analysis. +pub(super) struct SymbolCoverage { + uid: String, + name: String, + file: String, + line: u32, + kind: String, + test_callers: Vec, + test_caller_count: usize, + prod_caller_count: usize, + class: CoverageClass, +} + +/// Check whether a caller node is a test using FunctionMeta.is_test(). +/// +/// The archived `function_metas` vec is sorted by `node_idx`, so we use +/// binary search. On the archived type, `flags` is `ArchivedU16` and requires +/// `.to_native()` before bitwise ops. +fn archived_is_test(graph: &ecp_core::graph::ArchivedZeroCopyGraph, node_idx: usize) -> bool { + use ecp_core::graph::FunctionMeta; + let target = node_idx as u32; + match graph + .function_metas + .binary_search_by_key(&target, |m| m.node_idx.to_native()) + { + Ok(i) => graph.function_metas[i].flags.to_native() & FunctionMeta::FLAG_TEST != 0, + Err(_) => false, + } +} + +/// Classify a single symbol given its upstream callers (BFS result). +/// +/// `bfs_results` is the slice returned by `run_bfs`; depth-0 entry is the +/// symbol itself. Only depth > 0 entries (actual callers) are examined. +/// +/// `uid_idx` is the pre-built `uid → node_idx` table from +/// [`ecp_core::graph_query::build_uid_index`]. Building it once per +/// coverage analysis and passing it here avoids an O(N) linear scan over +/// all graph nodes for every BFS caller entry (T1-6 fast-path). +fn classify_symbol( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + view: Option<&OverlayView>, + symbol_idx: usize, + bfs_results: &[Value], + uid_idx: &rustc_hash::FxHashMap, +) -> SymbolCoverage { + let meta = merged_node_meta(graph, view, symbol_idx); + let uid = meta.uid.to_string(); + let name = meta.name; + let file = meta.file_path; + let line = meta.line; + let kind = meta.kind.to_string(); + + let mut test_callers: Vec = Vec::new(); + let mut prod_caller_count: usize = 0; + + for entry in bfs_results + .iter() + .filter(|e| e["depth"].as_u64().unwrap_or(0) > 0) + { + // O(1) uid → node_idx via pre-built FxHashMap (T1-6 fast-path). + // BFS JSON stores uid as a decimal string; parse back to u64 for lookup. + let caller_uid = entry["uid"].as_str().unwrap_or(""); + let caller_idx = caller_uid + .parse::() + .ok() + .and_then(|u| uid_idx.get(&u).map(|&i| i as usize)); + + let is_test = caller_idx + .map(|idx| archived_is_test(graph, idx)) + .unwrap_or(false); + + if is_test { + let caller_name = entry["name"].as_str().unwrap_or(caller_uid).to_string(); + test_callers.push(caller_name); + } else { + prod_caller_count += 1; + } + } + + let test_caller_count = test_callers.len(); + let class = match (test_caller_count, prod_caller_count) { + (0, 0) => CoverageClass::Orphan, + (0, _) => CoverageClass::Uncovered, + (t, p) if p > t * 3 => CoverageClass::Partial, + _ => CoverageClass::Covered, + }; + + SymbolCoverage { + uid, + name, + file, + line, + kind, + test_callers, + test_caller_count, + prod_caller_count, + class, + } +} + +#[allow(clippy::too_many_arguments)] +fn coverage_bfs_for_symbol( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + view: Option<&OverlayView>, + symbol_idx: usize, + requested_direction: &Direction, + existing_bfs: &[Value], + depth: usize, + min_conf: f32, + include_tests: bool, + rel_filter: &Option>, +) -> Vec { + if *requested_direction == Direction::Up { + return existing_bfs.to_vec(); + } + // Coverage analysis only consumes deterministic upstream callers — discard + // the heuristic / hidden-count fields from #264's expanded run_bfs return. + let (det_results, _heur, _hidden_conf, _hidden_heur) = run_bfs( + graph, + view, + symbol_idx, + &Direction::Up, + depth, + min_conf, + include_tests, + rel_filter, + false, // include_heuristic + ); + det_results +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn coverage_analyses( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + view: Option<&OverlayView>, + bfs_by_symbol: &[(usize, Vec)], + requested_direction: &Direction, + depth: usize, + min_conf: f32, + include_tests: bool, + rel_filter: &Option>, +) -> Vec { + // Build uid → node_idx once for the whole analysis. classify_symbol + // needs to reverse-look-up caller uids (from BFS JSON) back to node + // indices to call archived_is_test; without this table each caller + // entry would do an O(N) linear scan. (T1-6 fast-path) + let uid_idx = ecp_core::graph_query::build_uid_index(graph); + bfs_by_symbol + .iter() + .map(|(idx, bfs)| { + let coverage_bfs = coverage_bfs_for_symbol( + graph, + view, + *idx, + requested_direction, + bfs, + depth, + min_conf, + include_tests, + rel_filter, + ); + classify_symbol(graph, view, *idx, &coverage_bfs, &uid_idx) + }) + .collect() +} + +/// Build the `coverage` JSON section from a list of per-symbol analyses. +pub(super) fn build_coverage_json(analyses: Vec) -> Value { + let mut uncovered: Vec = Vec::new(); + let mut partial: Vec = Vec::new(); + let mut covered: Vec = Vec::new(); + let mut orphans: Vec = Vec::new(); + + for s in analyses { + let base = json!({ + "uid": s.uid, + "name": s.name, + "file": s.file, + "line": s.line, + "kind": s.kind, + "test_caller_count": s.test_caller_count, + "prod_caller_count": s.prod_caller_count, + }); + match s.class { + CoverageClass::Uncovered => uncovered.push(base), + CoverageClass::Partial => { + let mut v = base; + v["tests"] = json!(s.test_callers); + partial.push(v); + } + CoverageClass::Covered => { + let mut v = base; + v["tests"] = json!(s.test_callers); + covered.push(v); + } + CoverageClass::Orphan => orphans.push(base), + } + } + + let total_analyzed = uncovered.len() + partial.len() + covered.len() + orphans.len(); + json!({ + "summary": { + "uncovered": uncovered.len(), + "partial": partial.len(), + "covered": covered.len(), + "orphan": orphans.len(), + "total_analyzed": total_analyzed, + }, + "uncovered_symbols": uncovered, + "partial_symbols": partial, + "covered_symbols": covered, + "orphan_symbols": orphans, + }) +} diff --git a/crates/ecp-cli/src/commands/impact/literal.rs b/crates/ecp-cli/src/commands/impact/literal.rs new file mode 100644 index 00000000..81c56d59 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/literal.rs @@ -0,0 +1,416 @@ +use crate::engine::Engine; +use ecp_core::graph::NodeKind; +use ecp_core::EcpError; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// Build the payload for `ecp impact --literal `. Returns a JSON +/// object with the literal value and an array of sites: each site carries +/// file path, line, enclosing function name (if resolved), and the sink +/// classification (`sink:read|confidence:high`, `sink:write|confidence:medium`, +/// `sink:free|confidence:high`, etc.). +pub(super) fn build_literal_payload(value: &str, engine: &Engine) -> Result { + use ecp_core::graph::ArchivedRelType; + + let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; + + // Pre-build (target_node_idx → edge) map for UsesPathLiteral edges in a + // single O(|edges|) pass. Without this, the per-match `edges.iter().find` + // below was O(matches × edges) — at ~500k edges and a popular literal + // matching dozens of sites, that's tens of millions of comparisons and + // breaches the <30 ms per-query budget. + let lit_edge: HashMap = graph + .edges + .iter() + .filter(|e| matches!(e.rel_type, ArchivedRelType::UsesPathLiteral)) + .map(|e| (e.target.to_native(), e)) + .collect(); + + let mut sites: Vec = Vec::new(); + // `nodes_by_kind` walks the v10 CSR (kind_offsets / kind_node_idx) so we + // touch only PathLiteral entries, not the full nodes vec. + for idx_u32 in graph.nodes_by_kind(NodeKind::PathLiteral) { + let idx = idx_u32 as usize; + let node = &graph.nodes[idx]; + if node.name.resolve(&graph.string_pool) != value { + continue; + } + let file_node = &graph.files[node.file_idx.to_native() as usize]; + let file_path = file_node.path.resolve(&graph.string_pool); + + let (enclosing_name, sink_reason) = lit_edge + .get(&idx_u32) + .map(|e| { + let src_idx = e.source.to_native() as usize; + let src_name = graph.nodes[src_idx] + .name + .resolve(&graph.string_pool) + .to_string(); + let reason = e.reason.resolve(&graph.string_pool).to_string(); + (Some(src_name), reason) + }) + .unwrap_or((None, String::new())); + + sites.push(serde_json::json!({ + "file": file_path, + "line": node.start_line(), + "col": node.span.1.to_native(), + "enclosing": enclosing_name, + "sink_reason": sink_reason, + })); + } + + Ok(serde_json::json!({ + "literal": value, + "site_count": sites.len(), + "sites": sites, + })) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LiteralAccess { + ReadOnly, + WriteOnly, + MixedOrUnknown, +} + +#[derive(Debug, Clone)] +struct LiteralSite { + file: String, + line: u32, + col: u32, + enclosing: Option, + sink_reason: String, +} + +#[derive(Debug, Clone)] +struct LiteralGroup { + value: String, + sites: Vec, +} + +impl LiteralGroup { + fn access(&self) -> LiteralAccess { + let mut read_count = 0usize; + let mut write_count = 0usize; + + for site in &self.sites { + match sink_kind(&site.sink_reason) { + Some("read") | Some("open-read") => read_count += 1, + Some("write") | Some("open-write") => write_count += 1, + _ => return LiteralAccess::MixedOrUnknown, + } + } + + match (read_count, write_count) { + (r, 0) if r > 0 => LiteralAccess::ReadOnly, + (0, w) if w > 0 => LiteralAccess::WriteOnly, + _ => LiteralAccess::MixedOrUnknown, + } + } +} + +pub fn build_literal_coherence_payload(engine: &Engine) -> Result { + let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; + let groups = collect_literal_groups(graph); + let candidates = literal_coherence_candidates(&groups); + + Ok(json!({ + "literal_coherence": { + "candidate_count": candidates.len(), + "candidates": candidates, + "rules": { + "min_similarity": 0.85, + "same_extension": true, + "nearby_directory": true, + "access_split": "read-only vs write-only" + } + } + })) +} + +fn collect_literal_groups(graph: &ecp_core::graph::ArchivedZeroCopyGraph) -> Vec { + use ecp_core::graph::ArchivedRelType; + + let lit_edge: HashMap = graph + .edges + .iter() + .filter(|e| matches!(e.rel_type, ArchivedRelType::UsesPathLiteral)) + .map(|e| (e.target.to_native(), e)) + .collect(); + + let mut groups: HashMap = HashMap::new(); + for idx_u32 in graph.nodes_by_kind(NodeKind::PathLiteral) { + let node = &graph.nodes[idx_u32 as usize]; + let value = node.name.resolve(&graph.string_pool).to_string(); + let file_node = &graph.files[node.file_idx.to_native() as usize]; + let file = file_node.path.resolve(&graph.string_pool).to_string(); + let (enclosing, sink_reason) = lit_edge + .get(&idx_u32) + .map(|e| { + let src_idx = e.source.to_native() as usize; + let src_name = graph.nodes[src_idx] + .name + .resolve(&graph.string_pool) + .to_string(); + let reason = e.reason.resolve(&graph.string_pool).to_string(); + (Some(src_name), reason) + }) + .unwrap_or((None, String::new())); + + let group = groups.entry(value.clone()).or_insert_with(|| LiteralGroup { + value, + sites: Vec::new(), + }); + group.sites.push(LiteralSite { + file, + line: node.start_line(), + col: node.span.1.to_native(), + enclosing, + sink_reason, + }); + } + + groups.into_values().collect() +} + +fn literal_coherence_candidates(groups: &[LiteralGroup]) -> Vec { + const MIN_SIMILARITY: f64 = 0.85; + + let mut out = Vec::new(); + for (i, left) in groups.iter().enumerate() { + let left_access = left.access(); + if left_access == LiteralAccess::MixedOrUnknown { + continue; + } + for right in groups.iter().skip(i + 1) { + let right_access = right.access(); + if !matches!( + (left_access, right_access), + (LiteralAccess::ReadOnly, LiteralAccess::WriteOnly) + | (LiteralAccess::WriteOnly, LiteralAccess::ReadOnly) + ) { + continue; + } + if !same_extension(&left.value, &right.value) || !nearby_directory(left, right) { + continue; + } + + let similarity = literal_similarity(&left.value, &right.value); + if similarity < MIN_SIMILARITY { + continue; + } + + let (reader, writer) = if left_access == LiteralAccess::ReadOnly { + (left, right) + } else { + (right, left) + }; + out.push(json!({ + "reader_literal": reader.value, + "writer_literal": writer.value, + "similarity": similarity, + "confidence": "high", + "reader_sites": sites_json(&reader.sites), + "writer_sites": sites_json(&writer.sites), + })); + } + } + out.sort_by(|a, b| { + let a_similarity = a["similarity"].as_f64().unwrap_or(0.0); + let b_similarity = b["similarity"].as_f64().unwrap_or(0.0); + b_similarity + .partial_cmp(&a_similarity) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out +} + +fn sink_kind(reason: &str) -> Option<&str> { + reason.strip_prefix("sink:")?.split('|').next() +} + +fn sites_json(sites: &[LiteralSite]) -> Vec { + sites + .iter() + .map(|site| { + json!({ + "file": site.file, + "line": site.line, + "col": site.col, + "enclosing": site.enclosing, + "sink_reason": site.sink_reason, + }) + }) + .collect() +} + +fn same_extension(left: &str, right: &str) -> bool { + path_extension(left).is_some_and(|ext| Some(ext) == path_extension(right)) +} + +fn path_extension(path: &str) -> Option<&str> { + path.rsplit_once('.') + .map(|(_, ext)| ext) + .filter(|ext| !ext.is_empty() && !ext.contains('/') && !ext.contains('\\')) +} + +fn nearby_directory(left: &LiteralGroup, right: &LiteralGroup) -> bool { + left.sites.iter().any(|l| { + let ldir = parent_dir(&l.file); + right.sites.iter().any(|r| ldir == parent_dir(&r.file)) + }) +} + +fn parent_dir(path: &str) -> &str { + path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("") +} + +fn literal_similarity(left: &str, right: &str) -> f64 { + let left_base = basename_without_ext(left).to_ascii_lowercase(); + let right_base = basename_without_ext(right).to_ascii_lowercase(); + normalized_levenshtein(&left_base, &right_base) + .max(containment_similarity(&left_base, &right_base)) +} + +fn basename_without_ext(path: &str) -> &str { + let name = path.rsplit_once('/').map(|(_, n)| n).unwrap_or(path); + name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name) +} + +fn containment_similarity(left: &str, right: &str) -> f64 { + let (short, long) = if left.len() <= right.len() { + (left, right) + } else { + (right, left) + }; + if short.len() < 4 { + return 0.0; + } + let boundary_match = long == short + || long.starts_with(&format!("{short}_")) + || long.ends_with(&format!("_{short}")) + || long.contains(&format!("_{short}_")) + || long.starts_with(&format!("{short}-")) + || long.ends_with(&format!("-{short}")) + || long.contains(&format!("-{short}-")); + if boundary_match { + 0.9 + } else { + 0.0 + } +} + +fn normalized_levenshtein(left: &str, right: &str) -> f64 { + let max_len = left.chars().count().max(right.chars().count()); + if max_len == 0 { + return 1.0; + } + 1.0 - (levenshtein(left, right) as f64 / max_len as f64) +} + +fn levenshtein(left: &str, right: &str) -> usize { + let right_chars: Vec = right.chars().collect(); + let mut prev: Vec = (0..=right_chars.len()).collect(); + let mut curr = vec![0usize; right_chars.len() + 1]; + + for (i, lc) in left.chars().enumerate() { + curr[0] = i + 1; + for (j, &rc) in right_chars.iter().enumerate() { + let cost = usize::from(lc != rc); + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[right_chars.len()] +} + +#[cfg(test)] +mod literal_coherence_tests { + use super::*; + + fn group(value: &str, file: &str, sink_reason: &str) -> LiteralGroup { + LiteralGroup { + value: value.to_string(), + sites: vec![LiteralSite { + file: file.to_string(), + line: 1, + col: 0, + enclosing: Some("f".to_string()), + sink_reason: sink_reason.to_string(), + }], + } + } + + #[test] + fn coherence_finds_session_meta_split_brain_pair() { + let groups = vec![ + group( + "meta.json", + "src/session/read.rs", + "sink:read|confidence:high", + ), + group( + "session_meta.json", + "src/session/write.rs", + "sink:write|confidence:high", + ), + ]; + + let candidates = literal_coherence_candidates(&groups); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0]["reader_literal"], "meta.json"); + assert_eq!(candidates[0]["writer_literal"], "session_meta.json"); + } + + #[test] + fn coherence_rejects_same_access_pairs() { + let groups = vec![ + group("config.yml", "src/config/a.rs", "sink:read|confidence:high"), + group( + "configs.yml", + "src/config/b.rs", + "sink:read|confidence:high", + ), + ]; + + assert!(literal_coherence_candidates(&groups).is_empty()); + } + + #[test] + fn coherence_rejects_different_extensions() { + let groups = vec![ + group("Cargo.toml", "src/build/a.rs", "sink:read|confidence:high"), + group("Cargo.lock", "src/build/b.rs", "sink:write|confidence:high"), + ]; + + assert!(literal_coherence_candidates(&groups).is_empty()); + } + + #[test] + fn coherence_rejects_unknown_access_mixed_with_read() { + let mut reader = group( + "meta.json", + "src/session/read.rs", + "sink:read|confidence:high", + ); + reader.sites.push(LiteralSite { + file: "src/session/path.rs".to_string(), + line: 2, + col: 0, + enclosing: Some("path".to_string()), + sink_reason: "sink:join|confidence:medium".to_string(), + }); + let groups = vec![ + reader, + group( + "session_meta.json", + "src/session/write.rs", + "sink:write|confidence:high", + ), + ]; + + assert!(literal_coherence_candidates(&groups).is_empty()); + } +} diff --git a/crates/ecp-cli/src/commands/impact/mod.rs b/crates/ecp-cli/src/commands/impact/mod.rs new file mode 100644 index 00000000..d98a6764 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/mod.rs @@ -0,0 +1,432 @@ +mod baseline; +mod bfs; +mod coverage; +mod literal; +mod symbol; + +pub use literal::build_literal_coherence_payload; +pub use symbol::{run_for_symbol, LocalImpact}; + +use crate::engine::Engine; +use crate::output::{emit_with_caveat, merge_caveats, OutputFormat}; +use clap::{Args, ValueEnum}; +use ecp_core::config; +use ecp_core::{EcpError, HIGH_TRUST_CONFIDENCE}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +#[derive(ValueEnum, Clone, Debug, PartialEq)] +pub enum Direction { + #[value(alias = "upstream")] + Up, + #[value(alias = "downstream")] + Down, + Both, +} + +/// Default heuristic-edge confidence gate; mirrored by all three +/// `confidence_threshold: 0.85` sites (ImpactArgs default, build_payload's +/// inner construction, review/aggregate.rs). +pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.85; + +/// Symbol-level blast radius. From `` traverses call-graph for upstream +/// callers / downstream callees. From `--baseline ` +/// detects symbols changed vs the baseline and runs the same traversal per +/// change. For edge-level resolver delta (tier degradation, silent break), +/// use `ecp diff --section bindings` instead. +#[derive(Args, Debug)] +pub struct ImpactArgs { + /// Target symbol name (mutually exclusive with --baseline). Equivalent to + /// the `--target` named form below. Optional when `--batch` is set or + /// when a non-positional mode flag (`--target`, `--baseline`, `--literal`, + /// `--literal-coherence`) supplies the query. + #[arg(required_unless_present_any = ["target", "batch", "baseline", "literal", "literal_coherence"])] + pub name: Option, + + /// Named alias for the positional NAME argument — kept for parity with + /// old MCP / wrapper habits. + #[arg(long = "target", value_name = "TARGET", conflicts_with_all = ["name", "baseline", "batch"])] + pub target: Option, + + /// Git ref — compute blast radius across all symbols changed between + /// this baseline and HEAD. Mutually exclusive with positional . + #[arg(long, conflicts_with_all = ["name", "batch"])] + pub baseline: Option, + + /// Disambiguate when name has multiple matches: substring on file path. + /// `--file_path` / `--file-path` stay as aliases for back-compat. + #[arg(long = "file", alias = "file_path", alias = "file-path")] + pub file: Option, + + /// Disambiguate by kind (function | method | class | route | ...). + #[arg(long)] + pub kind: Option, + + /// Direction of traversal. + #[arg(long, value_enum, default_value_t = Direction::Up)] + pub direction: Direction, + + /// Maximum BFS depth. + #[arg(long, default_value_t = 5)] + pub depth: usize, + + /// Default OFF — recall-first: traverse every edge regardless of + /// confidence (cross-crate refs at 0.7 are still real callers, just + /// less certain). Pass `--high-trust-only=true` to restrict to + /// confidence ≥ 0.8 edges for a noise-light view; when filtering kicks + /// in, the output reports `hidden_edges` so missed coverage stays + /// visible. + #[arg(long, alias = "high_trust_only", default_value_t = false, action = clap::ArgAction::Set)] + pub high_trust_only: bool, + + /// Override the high-trust threshold with a custom value (0.0–1.0). + /// If set, takes precedence over --high-trust-only. + #[arg(long, alias = "min_confidence")] + pub min_confidence: Option, + + /// Include test files in traversal. + #[arg(long, aliases = ["include_tests", "includeTests"], default_value_t = false)] + pub include_tests: bool, + + /// Comma-separated relation types to follow (calls, extends, ...). + #[arg(long = "relation_types", alias = "relation-types")] + pub relation_types: Option, + + /// Repository selector. + #[arg(long)] + pub repo: Option, + + /// Coverage gap analysis: for each touched symbol, classify by test-caller + /// presence (uncovered / partial / covered). Uses FunctionMeta.is_test + /// flag from per-language extraction. Outputs uncovered symbols first to + /// support LLM PR review ("X 改了沒測試"). Implies --include-tests during + /// traversal so test callers are reachable from the walker. + #[arg(long, aliases = ["test_coverage", "testCoverage"], default_value_t = false)] + pub test_coverage: bool, + + /// Suppress heuristic callers (MirrorsField, EventTopicMirror) from the + /// blast radius. Default: heuristic callers ARE shown, in a separate + /// `heuristic_callers` bucket tagged `requires_verification`. Pass this + /// flag for a pure-deterministic blast radius. + #[arg(long, default_value_t = false)] + pub no_heuristic: bool, + + /// Informational confidence gate — promotes heuristic edges when T4-7/T5-33 + /// emit per-edge tiers. Currently controls the --explain-confidence report. + #[arg(long, default_value_t = DEFAULT_CONFIDENCE_THRESHOLD)] + pub confidence_threshold: f32, + + /// Emit explain_confidence block with threshold + per-tier filtered counts. + #[arg(long, default_value_t = false)] + pub explain_confidence: bool, + + /// Output format (mostly internal — agent doesn't set this). + #[arg(long)] + pub format: Option, + + /// List sites of a path-shaped string literal by exact value. + /// Mutually exclusive with --target/--baseline/. Returns JSON + /// with each site's file, line, enclosing fn, and sink classification + /// (`sink:read` / `sink:write` / `sink:open-read` / `sink:join` / etc). + /// Designed for LLM split-brain queries: `ecp impact --literal + /// session_meta.json` answers "where is this file read or written?" + /// without writing cypher. + #[arg(long = "literal", value_name = "VALUE", conflicts_with_all = ["name", "target", "baseline"])] + pub literal: Option, + + /// Auto-detect likely path-literal split-brain pairs across all + /// PathLiteral nodes. Conservative: same extension, similar basename, + /// nearby directories, and read-only vs write-only sink separation. + #[arg(long = "literal-coherence", conflicts_with_all = ["name", "target", "baseline", "literal", "batch"])] + pub literal_coherence: bool, + + /// Read target symbol names from stdin (one per line; `#` and blank lines + /// skipped). The graph is loaded once and N symbols are resolved + /// sequentially — amortises mmap + process spawn across queries. + /// Each result is prefixed by `=== target: ===` so callers can + /// split the stream unambiguously. Flags like --direction / --depth / + /// --include-tests apply uniformly to all targets. + /// + /// Symbol-mode only: `--batch` combined with `--baseline` or `--literal` + /// is rejected as an invalid argument. A positional name is also rejected + /// — stdin is the single source of targets, so a positional would be + /// silently ignored otherwise. + #[arg(long, conflicts_with_all = ["name", "baseline", "literal", "literal_coherence"])] + pub batch: bool, +} + +/// Split a comma-separated flag value into a normalized lowercase Vec. +/// Empty / whitespace-only parts are dropped so `--kind ,function,` works. +fn parse_csv_lower(s: Option<&str>) -> Option> { + s.map(|raw| { + raw.split(',') + .map(|p| p.trim().to_ascii_lowercase()) + .filter(|p| !p.is_empty()) + .collect() + }) +} + +/// Hints produced during impact computation and routed by `run`: stderr +/// nudges for the human/agent reading the terminal, plus a payload caveat. +/// Library callers via `build_payload` stay stderr-clean. +#[derive(Default)] +struct ImpactHints { + empty_hint_name: Option, + /// The empty-hint target is a field (Property); the hint adds the + /// field-read-coverage caveat. + empty_hint_is_field: bool, + /// If > 0, emit the hidden-edges footer. + hidden_edges: u64, + /// Heuristic edges hidden by the is_heuristic() filter (T-H1). + hidden_heuristic_edges: u64, + /// Payload caveat: the target name collides with other definitions, so + /// bare calls were Tier-3-suppressed at index time and the caller set is + /// a lower bound. Merged with `Engine::caveat()` into the `result` field + /// by `run`. + ambiguity_caveat: Option, +} + +pub fn run(args: ImpactArgs, engine: &Engine) -> Result<(), EcpError> { + if args.batch { + return run_batch(args, engine); + } + let format = OutputFormat::parse(args.format.as_deref()); + if args.literal_coherence { + let payload = build_literal_coherence_payload(engine)?; + return emit_with_caveat(&payload, format, engine.caveat()); + } + if let Some(literal_value) = args.literal.clone() { + let payload = literal::build_literal_payload(&literal_value, engine)?; + return emit_with_caveat(&payload, format, engine.caveat()); + } + let (payload, hints) = build_payload_with_hints(&args, engine)?; + if let Some(name) = &hints.empty_hint_name { + eprintln!( + "→ \"{name}\" exists but has 0 incoming references. Possible: entry point, dead code, or recent rename. Try --direction both / --include-tests" + ); + if hints.empty_hint_is_field { + eprintln!( + "→ \"{name}\" is a field: some languages don't capture field reads yet (JS class fields, Ruby attrs), so empty may mean uncaptured, not unread — grep to confirm" + ); + } + } + emit_hidden_edges_footer(hints.hidden_edges); + if args.no_heuristic && hints.hidden_heuristic_edges > 0 { + eprintln!( + "note: {} heuristic callers suppressed (--no-heuristic); drop the flag to see them", + hints.hidden_heuristic_edges + ); + } + emit_with_caveat( + &payload, + format, + merge_caveats(engine.caveat(), hints.ambiguity_caveat), + ) +} + +// ── Batch dispatch ──────────────────────────────────────────────────────────── + +/// Read target names from stdin, one per line (`#` and blank lines skipped). +/// +/// The graph is loaded once (by the caller via `Engine`) and N symbols are +/// resolved sequentially against it — amortises the mmap + process-spawn cost +/// that agents would otherwise pay for each single-target call. +/// +/// Output: each target's block is prefixed by `=== target: ===` so +/// callers can split the stream unambiguously regardless of `--format`. +/// Per-target fields are identical to single-target mode (status, target, +/// direction, impact, …). A target that fails to resolve (not found, ambiguous) +/// gets a per-target JSON error entry — the batch never aborts early. +fn run_batch(args: ImpactArgs, engine: &Engine) -> Result<(), EcpError> { + use std::io::BufRead; + + let format = OutputFormat::parse(args.format.as_deref()); + + let stdin = std::io::stdin(); + let targets: Vec = stdin + .lock() + .lines() + .map_while(Result::ok) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && !s.starts_with('#')) + .collect(); + + if targets.is_empty() { + eprintln!("→ batch: no targets on stdin (one symbol name per line, `#` for comments)"); + return Ok(()); + } + + let caveat = engine.caveat(); + + for target_name in &targets { + println!("=== target: {target_name} ==="); + + // Clone flags; substitute this target's name. + let per_target_args = ImpactArgs { + name: Some(target_name.clone()), + target: None, + baseline: None, + file: args.file.clone(), + kind: args.kind.clone(), + direction: args.direction.clone(), + depth: args.depth, + high_trust_only: args.high_trust_only, + min_confidence: args.min_confidence, + include_tests: args.include_tests, + relation_types: args.relation_types.clone(), + repo: args.repo.clone(), + test_coverage: args.test_coverage, + no_heuristic: args.no_heuristic, + confidence_threshold: args.confidence_threshold, + explain_confidence: args.explain_confidence, + format: args.format.clone(), + literal: None, + literal_coherence: false, + batch: false, + }; + + let payload = match build_payload_with_hints(&per_target_args, engine) { + Ok((p, hints)) => { + emit_hidden_edges_footer(hints.hidden_edges); + let merged = merge_caveats(caveat.clone(), hints.ambiguity_caveat); + // Inline the caveat into the payload so per-target output is + // self-contained (same contract as single-target mode's + // `emit_with_caveat`). + let mut p = p; + if let Some(c) = merged { + p["result"] = serde_json::json!(c); + } + p + } + Err(e) => { + serde_json::json!({ + "error": e.to_string(), + "target": target_name, + "status": "not_found", + }) + } + }; + emit_with_caveat(&payload, format, None)?; + } + Ok(()) +} + +/// Library API: returns the JSON payload only, dropping stderr hints. +/// +/// `run` (binary path) calls `build_payload_with_hints` directly so it can +/// print the hints to stderr, which means this thin wrapper has no in-crate +/// caller and `cargo` flags it as dead. Kept `pub` to mirror the 5-command +/// `build_payload` surface introduced in PR #88 for future library consumers. +#[allow(dead_code)] +pub fn build_payload(args: &ImpactArgs, engine: &Engine) -> Result { + build_payload_with_hints(args, engine).map(|(v, _)| v) +} + +fn build_payload_with_hints( + args: &ImpactArgs, + engine: &Engine, +) -> Result<(Value, ImpactHints), EcpError> { + let has_name = args.name.is_some() || args.target.is_some(); + match (has_name, args.baseline.as_ref()) { + (true, None) => symbol::impact_by_name(args, engine), + (false, Some(_)) => { + baseline::impact_with_baseline(args, engine).map(|v| (v, ImpactHints::default())) + } + (false, None) => Err(EcpError::InvalidArgument( + "impact requires a symbol (positional or --target ) or --baseline " + .into(), + )), + (true, Some(_)) => unreachable!("clap conflicts_with prevents this"), + } +} + +/// Attach the hidden-edge count to the JSON result when filtering actually +/// dropped something. Skipping the field when N=0 keeps default invocations +/// noise-free and lets callers branch on `result.get("hidden_edges")`. +fn attach_hidden_edges(result: &mut Value, hidden_edges: u64) { + if hidden_edges > 0 { + result["hidden_edges"] = json!(hidden_edges); + } +} + +/// Attach heuristic-filter fields to the JSON result object. +/// +/// `hidden_heuristic_edges` is always written (0 is safe — callers can branch +/// on the field existing). `heuristic_callers` section is always present when +/// `include_heuristic` is true (empty array allowed), with each entry tagged +/// `requires_verification: true` so consumers never mistake a heuristic lead +/// for a deterministic caller. +/// `explain_confidence` block is appended when the flag is set. +fn attach_heuristic_fields( + result: &mut Value, + hidden_heuristic_edges: u64, + heuristic_results: Vec, + include_heuristic: bool, + explain_confidence: bool, + confidence_threshold: f32, +) { + result["hidden_heuristic_edges"] = json!(hidden_heuristic_edges); + let heuristic_reached = heuristic_results.len() as u64; + if include_heuristic { + result["heuristic_callers"] = json!(tag_heuristic(heuristic_results)); + } + if explain_confidence { + result["explain_confidence"] = json!({ + "threshold": confidence_threshold, + "edges_filtered_by_tier": { + "unknown_tier": heuristic_reached + hidden_heuristic_edges, + }, + }); + } +} + +/// Tag each heuristic caller `requires_verification: true` so a consumer never +/// mistakes a ~0.85 heuristic lead for a 1.0 deterministic caller. +fn tag_heuristic(entries: Vec) -> Vec { + entries + .into_iter() + .map(|mut e| { + if let Some(obj) = e.as_object_mut() { + obj.insert("requires_verification".to_string(), json!(true)); + } + e + }) + .collect() +} + +/// Stderr footer mirroring `attach_hidden_edges` — emitted only when the +/// trust filter dropped at least one edge, routed to stderr so it doesn't +/// corrupt machine-readable JSON/TOON on stdout. +fn emit_hidden_edges_footer(hidden_edges: u64) { + if hidden_edges > 0 { + eprintln!( + "note: {hidden_edges} edges hidden by trust filter (drop --high-trust-only / --min-confidence to see all)" + ); + } +} + +/// Resolve the effective confidence threshold from `--min-confidence` / +/// `--high-trust-only` / repo config. +fn resolve_min_conf(args: &ImpactArgs) -> f32 { + let repo_root = args + .repo + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let cfg_threshold = config::load(&repo_root) + .map(|c| c.confidence.high_trust_threshold) + .unwrap_or(HIGH_TRUST_CONFIDENCE); + args.min_confidence.unwrap_or(if args.high_trust_only { + cfg_threshold + } else { + 0.0 + }) +} + +fn direction_str(dir: &Direction) -> &'static str { + match dir { + Direction::Up => "upstream", + Direction::Down => "downstream", + Direction::Both => "both", + } +} diff --git a/crates/ecp-cli/src/commands/impact/symbol.rs b/crates/ecp-cli/src/commands/impact/symbol.rs new file mode 100644 index 00000000..0a69cf22 --- /dev/null +++ b/crates/ecp-cli/src/commands/impact/symbol.rs @@ -0,0 +1,389 @@ +use super::bfs::{merged_node_meta, run_bfs}; +use super::coverage::{build_coverage_json, coverage_analyses}; +use super::{ + parse_csv_lower, resolve_min_conf, ImpactArgs, ImpactHints, DEFAULT_CONFIDENCE_THRESHOLD, +}; +use crate::commands::format::{kind_to_str, node_kind_to_str}; +use crate::commands::impact::{ + attach_heuristic_fields, attach_hidden_edges, direction_str, Direction, +}; +use crate::commands::symbol_id::{format_fqn, resolve_owner_class, split_fqn_target}; +use crate::engine::Engine; +use ecp_core::EcpError; +use serde_json::{json, Value}; +use std::collections::HashSet; + +// ── Per-symbol library API (used by `ecp group impact`) ───────────────────── + +/// Result of a single-symbol local impact computation. +/// +/// Wraps the JSON payload produced by `impact_by_name` so that callers can +/// extract the symbol UIDs touched by the traversal without re-parsing the +/// full payload themselves. +pub struct LocalImpact { + payload: Value, +} + +impl LocalImpact { + /// UIDs of every node reached by the BFS (depth 0 = the target itself). + /// Returns an empty vec when the payload carries an `"error"` field. + pub fn direct_symbol_uids(&self) -> Vec<&str> { + self.payload["impact"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v["uid"].as_str()).collect()) + .unwrap_or_default() + } + + /// Number of nodes in the BFS result (excluding the start node at depth 0). + pub fn direct_count(&self) -> usize { + self.payload["impact"] + .as_array() + .map(|arr| { + arr.iter() + .filter(|v| v["depth"].as_u64().unwrap_or(0) > 0) + .count() + }) + .unwrap_or(0) + } + + /// The full JSON payload — same shape as `ecp impact --format json`. + pub fn as_json(&self) -> &Value { + &self.payload + } +} + +/// Per-symbol impact computation callable without a CLI context. +/// +/// `member_repo` is the `dir_name` or alias of the indexed repo; it is used +/// only to resolve the confidence threshold from the repo config — the Engine +/// is provided by the caller, so no graph loading happens here. +/// +/// Returns `Ok(LocalImpact)` even when the symbol is not found in the graph +/// (the payload will carry an `"error"` field in that case), matching the +/// same graceful-degradation behaviour as `ecp impact --target X`. +pub fn run_for_symbol( + engine: &Engine, + member_repo: &str, + target: &str, + direction: &str, + max_depth: Option, + timeout_ms: Option, + include_tests: bool, +) -> Result { + let dir = match direction.to_ascii_lowercase().as_str() { + "downstream" | "down" => Direction::Down, + "both" => Direction::Both, + _ => Direction::Up, + }; + let args = ImpactArgs { + name: Some(target.to_string()), + target: None, + baseline: None, + file: None, + kind: None, + direction: dir, + depth: max_depth.unwrap_or(5) as usize, + high_trust_only: false, + min_confidence: None, + include_tests, + relation_types: None, + repo: Some(member_repo.to_string()), + test_coverage: false, + format: None, + no_heuristic: true, + confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD, + explain_confidence: false, + literal: None, + literal_coherence: false, + batch: false, + }; + let _ = timeout_ms; // timeout enforcement is caller-side; passed for API parity + let (payload, _hints) = super::build_payload_with_hints(&args, engine)?; + Ok(LocalImpact { payload }) +} + +pub(super) fn impact_by_name( + args: &ImpactArgs, + engine: &Engine, +) -> Result<(Value, ImpactHints), EcpError> { + let name = args + .name + .as_deref() + .or(args.target.as_deref()) + .expect("build_payload_with_hints gates on name||target"); + let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; + let view = engine.overlay_view(); + + // Split `Owner.Method` form for precise targeting. + let (owner_filter, bare_name) = split_fqn_target(name); + + // Resolve name → matching node indices, with optional --file / --kind + // disambiguation. FQN `Owner.Method` form is an additional filter on top. + let file_needle = args.file.as_deref(); + let kind_needle = args.kind.as_deref().map(|s| s.to_ascii_lowercase()); + + let mut same_name_defs = 0usize; + let mut matches: Vec = Vec::new(); + for (idx, node) in graph.nodes.iter().enumerate() { + if node.name.resolve(&graph.string_pool) != bare_name { + continue; + } + // Synthetic nodes (e.g. resolver-miss `Annotation` from + // `decorates_edges`) carry SYNTHETIC_FILE_IDX — they aren't + // real symbols at any file:line. Drop them from impact targets. + if !node.has_owning_file() { + continue; + } + // Working-tree truth: a dirty-file symbol deleted/renamed on disk + // (suppressed by the overlay view) is not a valid impact target, and + // deliberately stops counting toward `same_name_defs` — the ambiguity + // caveat describes the on-disk world, not the stale base graph. + if view.is_some_and(|v| v.redirect(idx as u32).is_none()) { + continue; + } + // Counted BEFORE --kind/--file/FQN narrowing: the Tier-3 resolver + // defence keys on the global name collision, not on whichever + // single def the user disambiguated to. + same_name_defs += 1; + if let Some(ref kn) = kind_needle { + let node_kind = kind_to_str(&node.kind).to_ascii_lowercase(); + if &node_kind != kn { + continue; + } + } + if let Some(needle) = file_needle { + let file_path = graph.files[node.file_idx.to_native() as usize] + .path + .resolve(&graph.string_pool); + if !file_path.contains(needle) { + continue; + } + } + if let Some(owner) = owner_filter { + if !resolve_owner_class(graph, idx) + .map(|oc| oc == owner) + .unwrap_or(false) + { + continue; + } + } + // A replaced base node enters the merged space as its virtual twin + // (on-disk spans; masked stale adjacency handled by run_bfs). + matches.push(match view { + Some(v) => v.redirect(idx as u32).expect("suppressed filtered above") as usize, + None => idx, + }); + } + // Symbols that only exist in the working tree (new functions in dirty + // files) — base-replacing twins are excluded: they arrived via redirect. + if let Some(v) = view { + for (i, vn) in v.virtual_nodes().iter().enumerate() { + if vn.replaced_base.is_some() || vn.name != bare_name { + continue; + } + same_name_defs += 1; + if let Some(ref kn) = kind_needle { + if &node_kind_to_str(&vn.kind).to_ascii_lowercase() != kn { + continue; + } + } + if let Some(needle) = file_needle { + if !vn.rel_path.contains(needle) { + continue; + } + } + if let Some(owner) = owner_filter { + if vn.owner_class.as_deref() != Some(owner) { + continue; + } + } + matches.push(v.base_len() as usize + i); + } + } + + if matches.is_empty() { + return Err(EcpError::InvalidArgument(format!( + "No symbol named '{}' found in graph. Try `ecp find --mode fuzzy` to find candidates, or check --file / --kind filters", + format_fqn(owner_filter, bare_name) + ))); + } + + // Multiple matches without disambiguation → report candidates then fail. + // FQN targeting (owner_filter present) already narrows by owner, so only + // fall into the ambiguous branch when the remaining options still exceed 1. + if matches.len() > 1 && file_needle.is_none() && kind_needle.is_none() { + let fqn_label = format_fqn(owner_filter, bare_name); + let candidates: Vec = matches + .iter() + .map(|&i| { + let meta = merged_node_meta(graph, view, i); + json!({ + "kind": meta.kind, + "filePath": meta.file_path, + "line": meta.line, + }) + }) + .collect(); + let candidate_lines = candidates + .iter() + .map(|candidate| { + format!( + " {},{},{}", + candidate["filePath"].as_str().unwrap_or(""), + candidate["kind"].as_str().unwrap_or(""), + candidate["line"].as_u64().unwrap_or(0) + ) + }) + .collect::>() + .join("\n"); + return Err(EcpError::AmbiguousSymbol { + name: fqn_label.to_string(), + count: matches.len(), + candidates: Some(candidate_lines), + }); + } + + let min_conf = resolve_min_conf(args); + let rel_filter = parse_csv_lower(args.relation_types.as_deref()); + // --test-coverage implies --include-tests so test callers are reachable. + let effective_include_tests = args.include_tests || args.test_coverage; + + let mut all_results: Vec = Vec::new(); + let mut all_heuristic_results: Vec = Vec::new(); + let mut hidden_edges_total: u64 = 0; + let mut hidden_heuristic_total: u64 = 0; + let mut per_match_bfs: Vec<(usize, Vec)> = Vec::new(); + for start_idx in &matches { + let (det_results, heur_results, hidden_conf, hidden_heur) = run_bfs( + graph, + view, + *start_idx, + &args.direction, + args.depth, + min_conf, + effective_include_tests, + &rel_filter, + !args.no_heuristic, + ); + all_results.extend(det_results.iter().cloned()); + per_match_bfs.push((*start_idx, det_results)); + all_heuristic_results.extend(heur_results); + hidden_edges_total += hidden_conf; + hidden_heuristic_total += hidden_heur; + } + + // Empty callers hint for upstream direction. + let impact_without_start: Vec<&Value> = all_results + .iter() + .filter(|e| e["depth"].as_u64().unwrap_or(0) > 0) + .collect(); + let emit_empty_hint = impact_without_start.is_empty() && args.direction == Direction::Up; + // A field target with no readers: the hint must flag that some languages + // don't model field reads yet, so empty != provably unread. + let empty_hint_is_field = emit_empty_hint + && all_results + .iter() + .any(|e| e["depth"].as_u64() == Some(0) && e["kind"].as_str() == Some("property")); + + // Collect unique file paths across ALL matches so the blind-spot warning + // is accurate when --file / --kind still leaves >1 match. + let mut seen_files = HashSet::new(); + let target_file_paths: Vec = matches + .iter() + .map(|&idx| merged_node_meta(graph, view, idx).file_path) + .filter(|p| seen_files.insert(p.clone())) + .collect(); + + let mut all_blind_spot_kinds: Vec = Vec::new(); + for fp in &target_file_paths { + all_blind_spot_kinds.extend(collect_blind_spots(graph, fp)); + } + + // Use the original user-supplied name (which may be FQN) as the target + // label in output — more precise than bare_name when owner was specified. + let mut result_obj = json!({ + "status": "success", + "target": format_fqn(owner_filter, bare_name), + "direction": direction_str(&args.direction), + "impact": all_results, + }); + attach_hidden_edges(&mut result_obj, hidden_edges_total); + attach_heuristic_fields( + &mut result_obj, + hidden_heuristic_total, + all_heuristic_results, + !args.no_heuristic, + args.explain_confidence, + args.confidence_threshold, + ); + + if !all_blind_spot_kinds.is_empty() { + let mut by_kind = std::collections::BTreeMap::::new(); + for k in &all_blind_spot_kinds { + *by_kind.entry(k.clone()).or_insert(0) += 1; + } + let files_field: serde_json::Value = if target_file_paths.len() == 1 { + json!(target_file_paths[0]) + } else { + json!(target_file_paths) + }; + result_obj["blind_spot_warning"] = json!({ + "file": files_field, + "total": all_blind_spot_kinds.len(), + "by_kind": by_kind, + "note": "traversal may be incomplete — see `ecp doctor` blind spots catalog", + }); + } + + if args.test_coverage { + let analyses = coverage_analyses( + graph, + view, + &per_match_bfs, + &args.direction, + args.depth, + min_conf, + effective_include_tests, + &rel_filter, + ); + result_obj["coverage"] = build_coverage_json(analyses); + } + + // FU-2026-05-29-011: with ≥2 same-named defs in the graph, the resolver + // suppressed every bare call to this name at index time + // (`DecisionTier::AmbiguousGlobal`), so the upstream caller set is a + // lower bound — the payload must say so instead of reading as complete. + let ambiguity_caveat = (same_name_defs >= 2 + && matches!(args.direction, Direction::Up | Direction::Both)) + .then(|| { + format!( + "caller set may be incomplete: {same_name_defs} same-named definitions of \ + '{bare_name}' exist, so bare calls (no import/qualifier context) were \ + ambiguity-suppressed at index time. Cross-check call sites with grep \ + before trusting the blast radius." + ) + }); + + Ok(( + result_obj, + ImpactHints { + empty_hint_name: emit_empty_hint.then(|| format_fqn(owner_filter, bare_name)), + empty_hint_is_field, + hidden_edges: hidden_edges_total, + hidden_heuristic_edges: hidden_heuristic_total, + ambiguity_caveat, + }, + )) +} + +pub(super) fn collect_blind_spots( + graph: &ecp_core::graph::ArchivedZeroCopyGraph, + target_file_path: &str, +) -> Vec { + graph + .blind_spots + .iter() + .filter(|bs| bs.file_path.resolve(&graph.string_pool) == target_file_path) + .map(|bs| bs.kind.resolve(&graph.string_pool).to_string()) + .collect() +} diff --git a/crates/ecp-cli/tests/impact_fixture_unit.rs b/crates/ecp-cli/tests/impact_fixture_unit.rs new file mode 100644 index 00000000..5eebb3fd --- /dev/null +++ b/crates/ecp-cli/tests/impact_fixture_unit.rs @@ -0,0 +1,383 @@ +//! Fixture-based unit tests for `impact`'s mode logic, driven directly +//! against `build_payload` / `run_for_symbol` — no `git init`, no +//! `ecp admin index` subprocess, no CLI binary spawn. +//! +//! Mirrors the synthetic-graph pattern in `impact_heuristic_filter.rs` and +//! `ecp_core::cypher::executor`'s `build_two_node` / `build_three_chain` +//! fixture helpers, but loads the serialized graph via `Engine::load` from +//! a tempdir file instead of driving it through the `ecp` binary. + +use ecp_cli::commands::impact::{run_for_symbol, ImpactArgs}; +use ecp_cli::engine::Engine; +use ecp_core::graph::{ + Edge, File, FileCategory, Node, NodeKind, RelType, ZeroCopyGraph, GRAPH_FORMAT_VERSION, + GRAPH_MAGIC, +}; +use ecp_core::pool::{StrRef, StringPool}; + +/// `entry(0) -[:Calls]-> middle(1) -[:Calls]-> leaf(2)`, one node per file. +/// Direction `Up` from `leaf` reaches `middle` then `entry`; `Down` from +/// `entry` reaches `middle` then `leaf`. +fn build_chain_graph() -> Vec { + let mut pool = StringPool::new(); + let file_entry = pool.add("src/entry.ts"); + let file_middle = pool.add("src/middle.ts"); + let file_leaf = pool.add("src/leaf.ts"); + + let name_entry = pool.add("entry"); + let name_middle = pool.add("middle"); + let name_leaf = pool.add("leaf"); + let reason_a = pool.add("call"); + let reason_b = pool.add("call"); + + let files = vec![ + File { + path: file_entry, + mtime: 0, + content_hash: [0; 8], + category: FileCategory::Source, + }, + File { + path: file_middle, + mtime: 0, + content_hash: [0; 8], + category: FileCategory::Source, + }, + File { + path: file_leaf, + mtime: 0, + content_hash: [0; 8], + category: FileCategory::Source, + }, + ]; + + let nodes = vec![ + Node { + uid: ecp_core::uid::compute(NodeKind::Function, "src/entry.ts", None, "entry"), + name: name_entry, + file_idx: 0, + kind: NodeKind::Function, + span: (0, 0, 2, 0), + community_id: 0, + owner_class: StrRef::default(), + content_hash: 0, + }, + Node { + uid: ecp_core::uid::compute(NodeKind::Function, "src/middle.ts", None, "middle"), + name: name_middle, + file_idx: 1, + kind: NodeKind::Function, + span: (0, 0, 2, 0), + community_id: 0, + owner_class: StrRef::default(), + content_hash: 0, + }, + Node { + uid: ecp_core::uid::compute(NodeKind::Function, "src/leaf.ts", None, "leaf"), + name: name_leaf, + file_idx: 2, + kind: NodeKind::Function, + span: (0, 0, 2, 0), + community_id: 0, + owner_class: StrRef::default(), + content_hash: 0, + }, + ]; + + // entry(0) -> middle(1) -> leaf(2) + let edges = vec![ + Edge { + source: 0, + target: 1, + rel_type: RelType::Calls, + confidence: 1.0, + reason: reason_a, + }, + Edge { + source: 1, + target: 2, + rel_type: RelType::Calls, + confidence: 1.0, + reason: reason_b, + }, + ]; + let out_offsets = vec![0u32, 1, 2, 2]; + let in_offsets = vec![0u32, 0, 1, 2]; + let in_edge_idx = vec![0u32, 1]; + let name_index: Vec = Vec::new(); + + let graph = ZeroCopyGraph { + magic: GRAPH_MAGIC, + version: GRAPH_FORMAT_VERSION, + fingerprint: [0; 32], + string_pool: pool.bytes, + files, + nodes, + edges, + out_offsets, + in_offsets, + in_edge_idx, + name_index, + process_start: 3, + traces_offsets: vec![0], + traces_data: vec![], + blind_spots: vec![], + route_shapes: vec![], + call_metas: vec![], + function_metas: vec![], + kind_offsets: vec![], + kind_node_idx: vec![], + node_flags: vec![], + }; + rkyv::to_bytes::(&graph) + .expect("serialize chain graph") + .into_vec() +} + +/// Two disconnected nodes both named `dup`, in different files — a caller +/// with a plain `dup` positional name cannot disambiguate without --file / +/// --kind. +fn build_ambiguous_graph() -> Vec { + let mut pool = StringPool::new(); + let file_a = pool.add("src/a.ts"); + let file_b = pool.add("src/b.ts"); + let name_dup = pool.add("dup"); + + let files = vec![ + File { + path: file_a, + mtime: 0, + content_hash: [0; 8], + category: FileCategory::Source, + }, + File { + path: file_b, + mtime: 0, + content_hash: [0; 8], + category: FileCategory::Source, + }, + ]; + let nodes = vec![ + Node { + uid: ecp_core::uid::compute(NodeKind::Function, "src/a.ts", None, "dup"), + name: name_dup, + file_idx: 0, + kind: NodeKind::Function, + span: (0, 0, 2, 0), + community_id: 0, + owner_class: StrRef::default(), + content_hash: 0, + }, + Node { + uid: ecp_core::uid::compute(NodeKind::Function, "src/b.ts", None, "dup"), + name: name_dup, + file_idx: 1, + kind: NodeKind::Function, + span: (0, 0, 2, 0), + community_id: 0, + owner_class: StrRef::default(), + content_hash: 0, + }, + ]; + let out_offsets = vec![0u32, 0, 0]; + let in_offsets = vec![0u32, 0, 0]; + let in_edge_idx: Vec = vec![]; + let name_index: Vec = Vec::new(); + + let graph = ZeroCopyGraph { + magic: GRAPH_MAGIC, + version: GRAPH_FORMAT_VERSION, + fingerprint: [0; 32], + string_pool: pool.bytes, + files, + nodes, + edges: vec![], + out_offsets, + in_offsets, + in_edge_idx, + name_index, + process_start: 2, + traces_offsets: vec![0], + traces_data: vec![], + blind_spots: vec![], + route_shapes: vec![], + call_metas: vec![], + function_metas: vec![], + kind_offsets: vec![], + kind_node_idx: vec![], + node_flags: vec![], + }; + rkyv::to_bytes::(&graph) + .expect("serialize ambiguous graph") + .into_vec() +} + +fn engine_from_bytes(bytes: &[u8]) -> (tempfile::TempDir, Engine) { + let tmp = tempfile::tempdir().unwrap(); + let graph_path = tmp.path().join("graph.bin"); + std::fs::write(&graph_path, bytes).unwrap(); + let engine = Engine::load(&graph_path).expect("engine load from tempdir graph.bin"); + (tmp, engine) +} + +fn base_args(name: &str) -> ImpactArgs { + ImpactArgs { + name: Some(name.to_string()), + target: None, + baseline: None, + file: None, + kind: None, + direction: ecp_cli::commands::impact::Direction::Up, + depth: 5, + high_trust_only: false, + min_confidence: None, + include_tests: false, + relation_types: None, + repo: None, + test_coverage: false, + no_heuristic: false, + confidence_threshold: ecp_cli::commands::impact::DEFAULT_CONFIDENCE_THRESHOLD, + explain_confidence: false, + format: None, + literal: None, + literal_coherence: false, + batch: false, + } +} + +#[test] +fn test_build_payload_symbol_mode_caller_chain_has_depth_and_file_fields() { + let (_tmp, engine) = engine_from_bytes(&build_chain_graph()); + let mut args = base_args("leaf"); + args.direction = ecp_cli::commands::impact::Direction::Up; + + let payload = ecp_cli::commands::impact::build_payload(&args, &engine) + .expect("build_payload should resolve `leaf` unambiguously"); + + assert_eq!(payload["status"], "success"); + assert_eq!(payload["target"], "leaf"); + let impact = payload["impact"] + .as_array() + .expect("`impact` must be an array"); + assert!( + impact.len() >= 3, + "expected leaf(depth0) + middle(depth1) + entry(depth2), got {impact:?}" + ); + + for entry in impact { + assert!(entry["depth"].is_u64(), "missing depth field: {entry}"); + assert!(entry["filePath"].is_string(), "missing filePath: {entry}"); + assert!(entry["name"].is_string(), "missing name: {entry}"); + } + + let names: Vec<&str> = impact.iter().filter_map(|e| e["name"].as_str()).collect(); + assert!(names.contains(&"leaf")); + assert!(names.contains(&"middle")); + assert!(names.contains(&"entry")); +} + +#[test] +fn test_build_payload_ambiguous_name_without_disambiguator_returns_error() { + let (_tmp, engine) = engine_from_bytes(&build_ambiguous_graph()); + let args = base_args("dup"); + + let err = ecp_cli::commands::impact::build_payload(&args, &engine) + .expect_err("two same-named `dup` defs with no --file/--kind must be ambiguous"); + + assert!( + matches!(err, ecp_core::EcpError::AmbiguousSymbol { count: 2, .. }), + "expected AmbiguousSymbol{{count: 2}}, got {err:?}" + ); +} + +#[test] +fn test_build_payload_ambiguous_name_with_file_disambiguator_succeeds() { + let (_tmp, engine) = engine_from_bytes(&build_ambiguous_graph()); + let mut args = base_args("dup"); + args.file = Some("a.ts".to_string()); + + let payload = ecp_cli::commands::impact::build_payload(&args, &engine) + .expect("--file narrows to a single `dup` definition"); + + assert_eq!(payload["status"], "success"); + let impact = payload["impact"].as_array().unwrap(); + assert_eq!(impact[0]["filePath"], "src/a.ts"); +} + +#[test] +fn test_build_payload_direction_up_vs_down_produce_different_results() { + let (_tmp, engine) = engine_from_bytes(&build_chain_graph()); + + let mut up_args = base_args("middle"); + up_args.direction = ecp_cli::commands::impact::Direction::Up; + let up_payload = ecp_cli::commands::impact::build_payload(&up_args, &engine).unwrap(); + let up_names: Vec = up_payload["impact"] + .as_array() + .unwrap() + .iter() + .filter_map(|e| e["name"].as_str().map(str::to_string)) + .collect(); + + let mut down_args = base_args("middle"); + down_args.direction = ecp_cli::commands::impact::Direction::Down; + let down_payload = ecp_cli::commands::impact::build_payload(&down_args, &engine).unwrap(); + let down_names: Vec = down_payload["impact"] + .as_array() + .unwrap() + .iter() + .filter_map(|e| e["name"].as_str().map(str::to_string)) + .collect(); + + assert!(up_names.contains(&"entry".to_string())); + assert!(!up_names.contains(&"leaf".to_string())); + + assert!(down_names.contains(&"leaf".to_string())); + assert!(!down_names.contains(&"entry".to_string())); + + assert_ne!(up_names, down_names); +} + +#[test] +fn test_build_payload_depth_limit_stops_traversal_before_full_chain() { + let (_tmp, engine) = engine_from_bytes(&build_chain_graph()); + let mut args = base_args("leaf"); + args.direction = ecp_cli::commands::impact::Direction::Up; + args.depth = 1; + + let payload = ecp_cli::commands::impact::build_payload(&args, &engine).unwrap(); + let names: Vec<&str> = payload["impact"] + .as_array() + .unwrap() + .iter() + .filter_map(|e| e["name"].as_str()) + .collect(); + + // depth=1 from `leaf` (Up): leaf itself (depth 0) + middle (depth 1). + // `entry` sits at depth 2 and must NOT be reached. + assert!(names.contains(&"leaf")); + assert!(names.contains(&"middle")); + assert!( + !names.contains(&"entry"), + "depth=1 BFS must not reach `entry` at depth 2: {names:?}" + ); +} + +#[test] +fn test_run_for_symbol_wraps_build_payload_and_exposes_direct_symbol_uids() { + let (_tmp, engine) = engine_from_bytes(&build_chain_graph()); + + let local = run_for_symbol(&engine, "member-repo", "leaf", "up", Some(5), None, false) + .expect("run_for_symbol should resolve `leaf`"); + + assert!( + local.direct_count() >= 2, + "expected middle + entry as callers, got direct_count={}", + local.direct_count() + ); + let uids = local.direct_symbol_uids(); + assert_eq!( + uids.len(), + local.as_json()["impact"].as_array().unwrap().len(), + "direct_symbol_uids length must match the raw impact array length" + ); +}