Skip to content

Commit 84b6e5f

Browse files
authored
feat(cli): find-transaction-patterns Outbox detector (T10-4 complete) (#323)
Implements the Outbox half of `find-transaction-patterns`: - 3-step detection: (1) name-scan Class/Struct nodes for outbox_event / event_outbox / message_outbox variants, (2) find writer functions via owner_class match (Strategy A) or References in-edge (Strategy B), (3) BFS depth-5 through Calls edges to reach Publishes-edge holders. - Confidence 0.80 (writer is method on table) or 0.75 (otherwise); requires_verification: true on all findings. - --outbox-only / --saga-only flags to narrow output (mutually exclusive). Prerequisite work (T5-33 subset): - `RawEventTopic` fields changed from StrRef to owned Box<str> to fix pool isolation bug (local StringPool was dropped before builder ran). - `post_process::event_topics::emit_edges` promotes RawEventTopics to EventTopic nodes + Publishes/Subscribes edges in the graph, enabling the BFS publisher lookup. - All 6 language parsers updated; 9 event-topic test files updated for the new Box<str> API.
1 parent 27229cb commit 84b6e5f

4 files changed

Lines changed: 676 additions & 47 deletions

File tree

crates/ecp-analyzer/src/event_topic/extract.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@ use ecp_core::analyzer::types::{PubSub, RawEventTopic, RawImport};
55
use rustc_hash::FxHashMap;
66
use streaming_iterator::StreamingIterator;
77
use tree_sitter::{Query, QueryCursor, Tree};
8+
// Note: StringPool import removed — RawEventTopic now uses owned Box<str>
89

910
/// Walk all captures produced by `query` against `tree`/`source`, dispatch
1011
/// each match to the first `EventTopicConfig` whose import-gate is satisfied,
1112
/// and emit a `RawEventTopic` for every accepted match.
1213
///
13-
/// `pool` is used to intern `topic_literal` and `enclosing_fn` strings.
14-
/// Callers typically pass the same pool used for the rest of the file's
15-
/// `LocalGraph` to maximise dedup across nodes.
16-
///
1714
/// The caller is responsible for supplying a query whose capture names align
1815
/// with the `topic_capture`, `producer_capture`, and `direction_capture`
1916
/// fields of at least one config. Captures not referenced by any active
@@ -29,8 +26,8 @@ use tree_sitter::{Query, QueryCursor, Tree};
2926
/// `RawEventTopic` has no `kind` field to distinguish queue-vs-exchange
3027
/// semantics (as AMQP does). The `direction_classifier` therefore returns
3128
/// `PubSub` (Publish/Subscribe direction), not a queue-topology kind.
32-
/// A `kind: Option<StrRef>` field should be added to `RawEventTopic` in a
33-
/// future append-only schema migration when T5-3 (RabbitMQ) lands.
29+
/// A `kind` field should be added to `RawEventTopic` in a future
30+
/// append-only schema migration when T5-3 (RabbitMQ) lands.
3431
pub fn extract_event_topics(
3532
tree: &Tree,
3633
source: &[u8],
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! `EventTopic` Node emission + `Publishes` / `Subscribes` edge construction
2+
//! (T10-4 partial / T5-33 subset).
3+
//!
4+
//! Bridges the gap between per-file `RawEventTopic` (T5-2..T5-31 detectors)
5+
//! and the queryable graph layer: each unique `(enclosing_fn, lib)` pair from
6+
//! a publish call site emits one `NodeKind::EventTopic` Node, and the enclosing
7+
//! function node receives a `RelType::Publishes` outgoing edge to it.
8+
//! Consumer call sites emit `RelType::Subscribes` edges analogously.
9+
//!
10+
//! ## Algorithm
11+
//!
12+
//! 1. **Dedup topics** — group `RawEventTopic` entries by `(enclosing_fn, lib)`
13+
//! to avoid duplicate `EventTopic` nodes when the same function calls the
14+
//! same produce API twice. `topic_literal` is used as the EventTopic name
15+
//! when available (dynamic topics with `None` are skipped — no fabricated
16+
//! nodes).
17+
//!
18+
//! 2. **Emit edges** — for each unique publish/subscribe site, resolve
19+
//! `enclosing_fn` via `SymbolTable` to find the owning Function/Method
20+
//! node in the same file. On miss, silently drop (no dangling edge).
21+
//!
22+
//! ## Scope note
23+
//!
24+
//! This module intentionally does NOT emit `EventTopicMirror` heuristic
25+
//! edges — that is T5-33's scope (requires at least one Publish + one
26+
//! Subscribe detector per lib to be gated correctly per D7). The mirror
27+
//! edges will be added by T5-33 without touching this file.
28+
29+
use crate::resolution::index::SymbolTable;
30+
use ecp_core::analyzer::types::{LocalGraph, PubSub};
31+
use ecp_core::graph::{Edge, Node, NodeKind, RelType};
32+
use ecp_core::pool::{StrRef, StringPool};
33+
use ecp_core::uid;
34+
use rustc_hash::FxHashMap;
35+
36+
/// Promote `RawEventTopic`s to `EventTopic` Nodes + `Publishes` / `Subscribes`
37+
/// edges. Returns the count of emitted `Publishes` + `Subscribes` edges.
38+
///
39+
/// Must run BEFORE the File-node append loop in `builder::build()` so that
40+
/// `EventTopic` node indices stay in the raw-symbols-and-extras region
41+
/// (consistent with how `SchemaField` nodes are placed by `schema_field_mirrors`).
42+
pub fn emit_edges(
43+
local_graphs: &[LocalGraph],
44+
symbol_table: &SymbolTable,
45+
string_pool: &mut StringPool,
46+
nodes: &mut Vec<Node>,
47+
edges: &mut Vec<Edge>,
48+
) -> usize {
49+
let reason_pub = string_pool.add("post_process:event_topic:publishes");
50+
let reason_sub = string_pool.add("post_process:event_topic:subscribes");
51+
52+
// Dedup map: normalised topic name → EventTopic node idx.
53+
let mut topic_node_idx: FxHashMap<String, u32> = FxHashMap::default();
54+
55+
let mut emitted = 0usize;
56+
57+
for (lg_idx, local_graph) in local_graphs.iter().enumerate() {
58+
let Some(ref event_topics) = local_graph.event_topics else {
59+
continue;
60+
};
61+
if event_topics.is_empty() {
62+
continue;
63+
}
64+
65+
let path_str = local_graph.file_path.to_string_lossy().replace('\\', "/");
66+
let file_idx = lg_idx as u32;
67+
68+
for raw_et in event_topics.iter() {
69+
// Skip dynamic topics without a literal.
70+
let Some(ref topic_lit) = raw_et.topic_literal else {
71+
continue;
72+
};
73+
74+
// Skip empty enclosing function (shouldn't happen in practice).
75+
if raw_et.enclosing_fn.is_empty() {
76+
continue;
77+
}
78+
79+
// Resolve enclosing function in this file's symbol table.
80+
let Some(fn_node_idx) = symbol_table.lookup_in_file(&path_str, &raw_et.enclosing_fn)
81+
else {
82+
continue;
83+
};
84+
85+
// Use the topic literal as the EventTopic name.
86+
let topic_name: &str = topic_lit;
87+
88+
// Emit or reuse EventTopic node, keyed by (topic_name, lib) to
89+
// keep a single canonical node per topic-per-framework.
90+
let et_key = format!("{}:{}", topic_name, raw_et.lib.as_str());
91+
let et_node_idx = *topic_node_idx.entry(et_key).or_insert_with(|| {
92+
let topic_name_ref: StrRef = string_pool.add(topic_name);
93+
let node_uid = uid::compute(NodeKind::EventTopic, &path_str, None, topic_name);
94+
let idx = nodes.len() as u32;
95+
nodes.push(Node {
96+
uid: node_uid,
97+
name: topic_name_ref,
98+
file_idx,
99+
kind: NodeKind::EventTopic,
100+
span: raw_et.span,
101+
community_id: 0,
102+
owner_class: StrRef::default(),
103+
content_hash: 0,
104+
});
105+
idx
106+
});
107+
108+
let (rel_type, reason) = match raw_et.direction {
109+
PubSub::Publish => (RelType::Publishes, reason_pub),
110+
PubSub::Subscribe => (RelType::Subscribes, reason_sub),
111+
};
112+
113+
edges.push(Edge {
114+
source: fn_node_idx,
115+
target: et_node_idx,
116+
rel_type,
117+
confidence: 0.9,
118+
reason,
119+
});
120+
emitted += 1;
121+
}
122+
}
123+
124+
emitted
125+
}

0 commit comments

Comments
 (0)