Skip to content

Commit 1255bcc

Browse files
authored
feat(cli): find-event-mirrors — list EventTopicMirror heuristic edges (T5-34) (#327)
* feat(cli): find-event-mirrors — list EventTopicMirror heuristic edges (T5-34) Adds `ecp find-event-mirrors` CLI command that surfaces all EventTopicMirror edges emitted by the T5-33 post-process pass. Filters: --min-confidence (default 0.0), --topic <glob>, --lib <name> (accepted, no-op until FrameworkId is persisted in archived nodes — documented in module doc). Formats: text (default, tabular), json, toon. Output: publisher_fn, subscriber_fn, canonical_topic, lib (null), confidence, tier, requires_verification. 10 integration tests covering: single-pair emit, lib filter no-op, topic glob match/no-match, min-confidence threshold, empty repo, text/json/toon format golden, two-topic multi-row. * fix(cli): add find-event-mirrors to TOP_LEVEL_COMMANDS invariant inventory
1 parent 60aaf07 commit 1255bcc

5 files changed

Lines changed: 686 additions & 0 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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+
}

crates/ecp-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod coverage;
44
pub mod cypher;
55
pub mod diff;
66
pub mod find;
7+
pub mod find_event_mirrors;
78
pub mod find_schema_bindings;
89
pub mod find_tx_patterns;
910
pub mod format;

crates/ecp-cli/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ enum Commands {
117117
/// blind-spot candidates (cross-owner-class fields that share the name
118118
/// but have no mirror edge). Accepts `Class.field` or bare `field`.
119119
FindSchemaBindings(commands::find_schema_bindings::FindSchemaBindingsArgs),
120+
/// List `EventTopicMirror` heuristic edges: (publisher_fn, subscriber_fn, topic, confidence).
121+
/// Edges are emitted by T5-33 at confidence=0.85; filter with --min-confidence, --topic, --lib.
122+
FindEventMirrors(commands::find_event_mirrors::FindEventMirrorsArgs),
120123
}
121124

122125
fn main() {
@@ -192,6 +195,7 @@ fn main() {
192195
Commands::Review(args) => args.repo.as_deref(),
193196
Commands::FindTransactionPatterns(args) => args.repo.as_deref(),
194197
Commands::FindSchemaBindings(_) => None,
198+
Commands::FindEventMirrors(_) => None,
195199
Commands::Coverage(_)
196200
| Commands::Contracts(_)
197201
| Commands::Diff(_)
@@ -235,6 +239,7 @@ fn main() {
235239
Commands::Review(args) => commands::review::run(args, &engine),
236240
Commands::FindTransactionPatterns(args) => commands::find_tx_patterns::run(args, &engine),
237241
Commands::FindSchemaBindings(args) => commands::find_schema_bindings::run(args, &engine),
242+
Commands::FindEventMirrors(args) => commands::find_event_mirrors::run(args, &engine),
238243
Commands::Coverage(_)
239244
| Commands::Contracts(_)
240245
| Commands::Diff(_)
@@ -279,6 +284,7 @@ fn check_group_atom(cli: &Cli) {
279284
Commands::Diff(a) => (a.repo.as_deref(), None),
280285
Commands::FindTransactionPatterns(a) => (a.repo.as_deref(), None),
281286
Commands::FindSchemaBindings(_) => return,
287+
Commands::FindEventMirrors(_) => return,
282288
_ => return,
283289
};
284290
// The vast majority of invocations don't pass `--repo` at all, so the

crates/ecp-cli/tests/cli_surface_invariants.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const TOP_LEVEL_COMMANDS: &[&str] = &[
7171
"review",
7272
"find-transaction-patterns",
7373
"find-schema-bindings",
74+
"find-event-mirrors",
7475
// Hidden
7576
"admin",
7677
"group",

0 commit comments

Comments
 (0)