|
| 1 | +//! `ecp find-event-mirrors` — list `EventTopicMirror` heuristic edges (T5-34). |
| 2 | +//! |
| 3 | +//! ## What this does |
| 4 | +//! |
| 5 | +//! Scans the indexed graph for all `EventTopicMirror` edges emitted by the |
| 6 | +//! T5-33 post-process pass and surfaces each paired (publisher_fn, |
| 7 | +//! subscriber_fn, topic, confidence) tuple so LLMs can reason about |
| 8 | +//! publish/subscribe event flow without knowing producer/consumer file paths. |
| 9 | +//! |
| 10 | +//! ## Graph shape walked |
| 11 | +//! |
| 12 | +//! ```text |
| 13 | +//! (publisher_fn) -[Publishes]-> (EventTopic:pub) -[EventTopicMirror]-> (EventTopic:sub) <-[Subscribes]- (subscriber_fn) |
| 14 | +//! ``` |
| 15 | +//! |
| 16 | +//! For each `EventTopicMirror` edge `pub_et → sub_et`: |
| 17 | +//! 1. Resolve `publisher_fn` via incoming `Publishes` edge on `pub_et`. |
| 18 | +//! 2. Resolve `subscriber_fn` via incoming `Subscribes` edge on `sub_et`. |
| 19 | +//! 3. Read `canonical_topic` from `pub_et.name` (both nodes share the same |
| 20 | +//! canonical topic string — guaranteed by the same-lib-keyed bucket in |
| 21 | +//! `post_process::event_topic_mirrors`). |
| 22 | +//! |
| 23 | +//! ## Lib column |
| 24 | +//! |
| 25 | +//! The `FrameworkId` used to gate same-lib pairing at build time is NOT |
| 26 | +//! persisted on archived nodes or edges — it serves only as a bucket key |
| 27 | +//! during the post-process pass. The `lib` column is therefore `null` in |
| 28 | +//! all outputs. The `--lib` flag is accepted for forward-compatibility but |
| 29 | +//! cannot filter on a null value; use `--topic` to narrow by canonical topic |
| 30 | +//! string instead. A future schema extension (T5-33-followup) that persists |
| 31 | +//! the framework on `EventTopic` nodes will make `--lib` functional. |
| 32 | +//! |
| 33 | +//! ## Confidence |
| 34 | +//! |
| 35 | +//! All `EventTopicMirror` edges are emitted with `confidence=0.85` by T5-33 |
| 36 | +//! (`is_heuristic()` returns `true`). `--min-confidence 0.86` therefore |
| 37 | +//! returns 0 rows; `--min-confidence 0.85` returns all. |
| 38 | +
|
| 39 | +use crate::engine::Engine; |
| 40 | +use crate::output::{emit, OutputFormat}; |
| 41 | +use clap::Args; |
| 42 | +use ecp_core::graph::{ArchivedNodeKind, ArchivedRelType, ArchivedZeroCopyGraph}; |
| 43 | +use ecp_core::EcpError; |
| 44 | +use serde_json::{json, Value}; |
| 45 | + |
| 46 | +/// Args for `ecp find-event-mirrors`. |
| 47 | +#[derive(Args, Debug, Clone)] |
| 48 | +pub struct FindEventMirrorsArgs { |
| 49 | + /// Filter by framework/transport lib (kafka, redis, rabbitmq, sqs, celery). |
| 50 | + /// Accepted for forward-compatibility; currently no-op because FrameworkId |
| 51 | + /// is not persisted in the archived graph (see module doc). |
| 52 | + #[arg(long, value_name = "LIB")] |
| 53 | + pub lib: Option<String>, |
| 54 | + |
| 55 | + /// Minimum confidence threshold (default 0.0 — show all mirrors). |
| 56 | + /// T5-33 emits EventTopicMirror edges at confidence=0.85; setting |
| 57 | + /// --min-confidence >0.85 will return 0 rows. |
| 58 | + #[arg(long, value_name = "F", default_value = "0.0")] |
| 59 | + pub min_confidence: f32, |
| 60 | + |
| 61 | + /// Glob pattern matched against the canonical topic name. |
| 62 | + /// Example: `--topic 'orders/*'` matches `orders/created`, `orders/updated`. |
| 63 | + #[arg(long, value_name = "PATTERN")] |
| 64 | + pub topic: Option<String>, |
| 65 | + |
| 66 | + /// Output format: text (default) | json | toon |
| 67 | + #[arg(long)] |
| 68 | + pub format: Option<String>, |
| 69 | +} |
| 70 | + |
| 71 | +// ── Tier helper ─────────────────────────────────────────────────────────────── |
| 72 | + |
| 73 | +fn tier_label(confidence: f32) -> &'static str { |
| 74 | + if confidence >= 0.85 { |
| 75 | + "HEURISTIC" |
| 76 | + } else { |
| 77 | + "BLIND_SPOT" |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +// ── Graph traversal helpers ─────────────────────────────────────────────────── |
| 82 | + |
| 83 | +/// Collect (node_idx, confidence) pairs reachable from `node_idx` via |
| 84 | +/// outgoing `EventTopicMirror` edges. |
| 85 | +fn mirror_targets(graph: &ArchivedZeroCopyGraph, node_idx: usize) -> Vec<(usize, f32)> { |
| 86 | + let out_start = graph.out_offsets[node_idx].to_native() as usize; |
| 87 | + let out_end = graph.out_offsets[node_idx + 1].to_native() as usize; |
| 88 | + (out_start..out_end) |
| 89 | + .filter_map(|i| { |
| 90 | + let edge = &graph.edges[i]; |
| 91 | + if matches!(edge.rel_type, ArchivedRelType::EventTopicMirror) { |
| 92 | + Some(( |
| 93 | + edge.target.to_native() as usize, |
| 94 | + edge.confidence.to_native(), |
| 95 | + )) |
| 96 | + } else { |
| 97 | + None |
| 98 | + } |
| 99 | + }) |
| 100 | + .collect() |
| 101 | +} |
| 102 | + |
| 103 | +/// Selector for which directional edge to follow when resolving an endpoint. |
| 104 | +#[derive(Clone, Copy)] |
| 105 | +enum Direction { |
| 106 | + Publish, |
| 107 | + Subscribe, |
| 108 | +} |
| 109 | + |
| 110 | +/// Find the first function/method node that has an incoming `Publishes` or |
| 111 | +/// `Subscribes` edge pointing at `et_idx` (an `EventTopic` node). Returns |
| 112 | +/// `None` when no directional edge was emitted (anonymous callback / lookup |
| 113 | +/// miss in the post-process pass). |
| 114 | +fn resolve_fn_node(graph: &ArchivedZeroCopyGraph, et_idx: usize, dir: Direction) -> Option<usize> { |
| 115 | + let in_start = graph.in_offsets[et_idx].to_native() as usize; |
| 116 | + let in_end = graph.in_offsets[et_idx + 1].to_native() as usize; |
| 117 | + for i in in_start..in_end { |
| 118 | + let edge_idx = graph.in_edge_idx[i].to_native() as usize; |
| 119 | + let edge = &graph.edges[edge_idx]; |
| 120 | + let matches_dir = match dir { |
| 121 | + Direction::Publish => matches!(edge.rel_type, ArchivedRelType::Publishes), |
| 122 | + Direction::Subscribe => matches!(edge.rel_type, ArchivedRelType::Subscribes), |
| 123 | + }; |
| 124 | + if matches_dir { |
| 125 | + return Some(edge.source.to_native() as usize); |
| 126 | + } |
| 127 | + } |
| 128 | + None |
| 129 | +} |
| 130 | + |
| 131 | +/// Build the JSON object for a function/method endpoint node. Falls back to |
| 132 | +/// `null` fields when no directional edge resolved to a concrete function. |
| 133 | +fn endpoint_json(graph: &ArchivedZeroCopyGraph, fn_idx_opt: Option<usize>) -> Value { |
| 134 | + match fn_idx_opt { |
| 135 | + None => json!({ "name": null, "file": null, "line": null }), |
| 136 | + Some(fn_idx) => { |
| 137 | + let node = &graph.nodes[fn_idx]; |
| 138 | + let name = node.name.resolve(&graph.string_pool); |
| 139 | + let file_node = &graph.files[node.file_idx.to_native() as usize]; |
| 140 | + let file = file_node.path.resolve(&graph.string_pool); |
| 141 | + let line = node.span.0.to_native(); |
| 142 | + json!({ "name": name, "file": file, "line": line }) |
| 143 | + } |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// ── Text format ─────────────────────────────────────────────────────────────── |
| 148 | + |
| 149 | +fn render_text(mirrors: &[Value]) -> String { |
| 150 | + if mirrors.is_empty() { |
| 151 | + return String::from("(no event mirrors found)"); |
| 152 | + } |
| 153 | + let col_w = [30usize, 30, 24, 8, 10]; // publisher, subscriber, topic, lib, confidence |
| 154 | + let header = format!( |
| 155 | + "{:<w0$} {:<w1$} {:<w2$} {:<w3$} {}", |
| 156 | + "publisher_fn", |
| 157 | + "subscriber_fn", |
| 158 | + "topic", |
| 159 | + "lib", |
| 160 | + "confidence", |
| 161 | + w0 = col_w[0], |
| 162 | + w1 = col_w[1], |
| 163 | + w2 = col_w[2], |
| 164 | + w3 = col_w[3], |
| 165 | + ); |
| 166 | + let sep = "-".repeat(header.len()); |
| 167 | + |
| 168 | + let mut lines = vec![header, sep]; |
| 169 | + for m in mirrors { |
| 170 | + let pub_name = m["publisher"]["name"].as_str().unwrap_or("?"); |
| 171 | + let sub_name = m["subscriber"]["name"].as_str().unwrap_or("?"); |
| 172 | + let topic = m["topic"].as_str().unwrap_or("?"); |
| 173 | + let lib = m["lib"].as_str().unwrap_or("null"); |
| 174 | + let conf = m["confidence"].as_f64().unwrap_or(0.0); |
| 175 | + lines.push(format!( |
| 176 | + "{:<w0$} {:<w1$} {:<w2$} {:<w3$} {:.2}", |
| 177 | + pub_name, |
| 178 | + sub_name, |
| 179 | + topic, |
| 180 | + lib, |
| 181 | + conf, |
| 182 | + w0 = col_w[0], |
| 183 | + w1 = col_w[1], |
| 184 | + w2 = col_w[2], |
| 185 | + w3 = col_w[3], |
| 186 | + )); |
| 187 | + } |
| 188 | + lines.join("\n") |
| 189 | +} |
| 190 | + |
| 191 | +// ── Glob matching ───────────────────────────────────────────────────────────── |
| 192 | + |
| 193 | +/// Minimal glob: supports `*` (any sequence of non-`/` chars) and `**` |
| 194 | +/// (any sequence including `/`). No character classes or `?`. Anchored |
| 195 | +/// on both ends. |
| 196 | +fn glob_matches(pattern: &str, topic: &str) -> bool { |
| 197 | + glob_match_inner(pattern.as_bytes(), topic.as_bytes()) |
| 198 | +} |
| 199 | + |
| 200 | +fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool { |
| 201 | + match pat.split_first() { |
| 202 | + None => s.is_empty(), |
| 203 | + Some((&b'*', rest)) => { |
| 204 | + // Check for `**` |
| 205 | + if rest.first() == Some(&b'*') { |
| 206 | + let rest2 = &rest[1..]; |
| 207 | + // ** matches zero or more chars including / |
| 208 | + for i in 0..=s.len() { |
| 209 | + if glob_match_inner(rest2, &s[i..]) { |
| 210 | + return true; |
| 211 | + } |
| 212 | + } |
| 213 | + false |
| 214 | + } else { |
| 215 | + // * matches zero or more non-/ chars |
| 216 | + for i in 0..=s.len() { |
| 217 | + if s[..i].contains(&b'/') { |
| 218 | + break; |
| 219 | + } |
| 220 | + if glob_match_inner(rest, &s[i..]) { |
| 221 | + return true; |
| 222 | + } |
| 223 | + } |
| 224 | + false |
| 225 | + } |
| 226 | + } |
| 227 | + Some((&ph, rest_p)) => match s.split_first() { |
| 228 | + None => false, |
| 229 | + Some((&sh, rest_s)) => ph == sh && glob_match_inner(rest_p, rest_s), |
| 230 | + }, |
| 231 | + } |
| 232 | +} |
| 233 | + |
| 234 | +// ── Core detection ──────────────────────────────────────────────────────────── |
| 235 | + |
| 236 | +struct MirrorRow { |
| 237 | + pub_fn_idx: Option<usize>, |
| 238 | + sub_fn_idx: Option<usize>, |
| 239 | + topic: String, |
| 240 | + confidence: f32, |
| 241 | +} |
| 242 | + |
| 243 | +fn collect_mirrors( |
| 244 | + graph: &ArchivedZeroCopyGraph, |
| 245 | + min_confidence: f32, |
| 246 | + topic_glob: Option<&str>, |
| 247 | + lib_filter: Option<&str>, |
| 248 | +) -> Vec<MirrorRow> { |
| 249 | + // Walk all EventTopic nodes and collect outgoing EventTopicMirror edges. |
| 250 | + let mut rows: Vec<MirrorRow> = Vec::new(); |
| 251 | + |
| 252 | + for (et_pub_idx, node) in graph.nodes.iter().enumerate() { |
| 253 | + if !matches!(node.kind, ArchivedNodeKind::EventTopic) { |
| 254 | + continue; |
| 255 | + } |
| 256 | + |
| 257 | + let topic_name = node.name.resolve(&graph.string_pool); |
| 258 | + |
| 259 | + // Topic glob filter. |
| 260 | + if let Some(pat) = topic_glob { |
| 261 | + if !glob_matches(pat, topic_name) { |
| 262 | + continue; |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + for (et_sub_idx, confidence) in mirror_targets(graph, et_pub_idx) { |
| 267 | + if confidence < min_confidence { |
| 268 | + continue; |
| 269 | + } |
| 270 | + |
| 271 | + // lib_filter: FrameworkId is not persisted in the archived graph |
| 272 | + // (see module doc). Accept all rows when --lib is set, matching the |
| 273 | + // documented no-op behavior until the schema is extended. |
| 274 | + let _ = lib_filter; // forward-compat placeholder |
| 275 | + |
| 276 | + let pub_fn_idx = resolve_fn_node(graph, et_pub_idx, Direction::Publish); |
| 277 | + let sub_fn_idx = resolve_fn_node(graph, et_sub_idx, Direction::Subscribe); |
| 278 | + |
| 279 | + rows.push(MirrorRow { |
| 280 | + pub_fn_idx, |
| 281 | + sub_fn_idx, |
| 282 | + topic: topic_name.to_owned(), |
| 283 | + confidence, |
| 284 | + }); |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + rows |
| 289 | +} |
| 290 | + |
| 291 | +fn row_to_json(graph: &ArchivedZeroCopyGraph, row: &MirrorRow) -> Value { |
| 292 | + json!({ |
| 293 | + "publisher": endpoint_json(graph, row.pub_fn_idx), |
| 294 | + "subscriber": endpoint_json(graph, row.sub_fn_idx), |
| 295 | + "topic": row.topic, |
| 296 | + // lib: null — FrameworkId not persisted in archived graph (see module doc). |
| 297 | + "lib": null, |
| 298 | + "confidence": row.confidence, |
| 299 | + "tier": tier_label(row.confidence), |
| 300 | + "requires_verification": true, |
| 301 | + }) |
| 302 | +} |
| 303 | + |
| 304 | +// ── Entry point ─────────────────────────────────────────────────────────────── |
| 305 | + |
| 306 | +pub fn run(args: FindEventMirrorsArgs, engine: &Engine) -> Result<(), EcpError> { |
| 307 | + let graph = engine.graph().map_err(|e| EcpError::Rkyv(e.to_string()))?; |
| 308 | + let format = OutputFormat::parse(args.format.as_deref()); |
| 309 | + |
| 310 | + let rows = collect_mirrors( |
| 311 | + graph, |
| 312 | + args.min_confidence, |
| 313 | + args.topic.as_deref(), |
| 314 | + args.lib.as_deref(), |
| 315 | + ); |
| 316 | + let mirror_count = rows.len(); |
| 317 | + let mirrors: Vec<Value> = rows.iter().map(|r| row_to_json(graph, r)).collect(); |
| 318 | + |
| 319 | + // Text format: table to stdout directly (not wrapped in JSON envelope). |
| 320 | + if matches!(format, OutputFormat::Text) { |
| 321 | + println!("{}", render_text(&mirrors)); |
| 322 | + return Ok(()); |
| 323 | + } |
| 324 | + |
| 325 | + let result = json!({ |
| 326 | + "mirrors": mirrors, |
| 327 | + "summary": { |
| 328 | + "mirror_count": mirror_count, |
| 329 | + "lib_filter": args.lib, |
| 330 | + "topic_filter": args.topic, |
| 331 | + "min_confidence": args.min_confidence, |
| 332 | + "lib_note": "FrameworkId not persisted in archived graph; --lib is a no-op until schema is extended (T5-33-followup)", |
| 333 | + }, |
| 334 | + }); |
| 335 | + |
| 336 | + emit(&result, format) |
| 337 | +} |
0 commit comments