Skip to content

Commit 343a45d

Browse files
committed
chore(build): fix main compile after T4-7 (Box<str> RawSchemaField) + T7-2 (Node content_hash/owner_class)
The merges of #291 (T4-7) and #292 (T7-2) left two downstream files behind. Required by this branch's pre-push clippy gate; expected to drop on rebase once fix/main-compile-post-291-292 lands. - crates/ecp-analyzer/src/protobuf/parser.rs: RawSchemaField now stores Box<str>, not StrRef; drop the per-call StringPool plumbing. - crates/ecp-analyzer/src/post_process/schema_field_mirrors.rs: Node gained content_hash + owner_class fields and SchemaField UIDs are now computed via uid::compute, not pool-interned format!() strings.
1 parent a888812 commit 343a45d

2 files changed

Lines changed: 200 additions & 6 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//! `SchemaField` Node emission + `MirrorsField` heuristic edge bucketing
2+
//! (T4-7).
3+
//!
4+
//! Bridges the gap between per-file `RawSchemaField` (T4-1..T4-6 detectors)
5+
//! and the queryable graph layer: each `RawSchemaField` becomes a
6+
//! `NodeKind::SchemaField` Node connected to its owning class via
7+
//! `HasProperty`, and cross-framework mirrors (e.g. Pydantic `User.email`
8+
//! vs SQLAlchemy `User.email`) are linked by the heuristic `MirrorsField`
9+
//! edge.
10+
//!
11+
//! ## Algorithm
12+
//!
13+
//! 1. **Promote**: iterate `LocalGraph.schema_fields`, emit one
14+
//! `SchemaField` Node + one `HasProperty` edge (Class → SchemaField)
15+
//! per field. SymbolTable lookup resolves the owning class name in
16+
//! the same file; misses silently drop the field (no false-positive
17+
//! edge to a wrong class).
18+
//!
19+
//! 2. **Bucket**: group emitted SchemaField Nodes by
20+
//! `(name.to_lowercase(), SchemaType)` so case-variants and
21+
//! type-compatible mirrors share a bucket.
22+
//!
23+
//! 3. **Pair within bucket**: apply the 4-point strict rubric per pair —
24+
//! exact case-sensitive name, identical `SchemaType` (granted by
25+
//! bucket key), identical owner-class name, and bidirectional top-1.
26+
//! For k=2 the top-1 check is trivial; for k≥3 with a uniform
27+
//! `(name, type, owner_class)` triple, D3 cluster semantics emit all
28+
//! `k×(k−1)/2` pairs at confidence 0.9. Different owner-class within
29+
//! the same bucket is currently dropped (BlindSpot emission is a
30+
//! documented follow-up — see `test_partial_match_emits_blindspot`).
31+
//!
32+
//! ## Perf
33+
//!
34+
//! - O(N) bucket build; O(k²) pairwise emission where k = #fields sharing
35+
//! `(name_lc, type)`. Typical k < 10 (real corpora rarely have more
36+
//! than a handful of cross-framework mirrors per identifier).
37+
//! - Offline-only — runs once at build time, never on `ecp` hot paths.
38+
39+
use crate::resolution::index::SymbolTable;
40+
use ecp_core::analyzer::types::{LocalGraph, SchemaType};
41+
use ecp_core::graph::{Edge, Node, NodeKind, RelType};
42+
use ecp_core::pool::StringPool;
43+
use ecp_core::uid;
44+
use rustc_hash::FxHashMap;
45+
46+
/// Promote `RawSchemaField`s to `SchemaField` Nodes + emit `HasProperty`
47+
/// connections + `MirrorsField` heuristic edges. Returns the count of
48+
/// emitted MirrorsField edges (HasProperty + SchemaField Node counts are
49+
/// derivable from `nodes.len()` delta; this return is for telemetry).
50+
///
51+
/// `file_node_count_before` is the size of `nodes` BEFORE the File-node
52+
/// append loop runs — used to bound SchemaField nodes to the
53+
/// raw-symbols-and-extras region so `file_node_idx` registration further
54+
/// downstream still hits the correct File-node range.
55+
pub fn emit_edges(
56+
local_graphs: &[LocalGraph],
57+
symbol_table: &SymbolTable,
58+
string_pool: &mut StringPool,
59+
nodes: &mut Vec<Node>,
60+
edges: &mut Vec<Edge>,
61+
) -> usize {
62+
let reason_has_property = string_pool.add("post_process:schema_field:has_property");
63+
let reason_mirror = string_pool.add("post_process:schema_field:mirrors_field");
64+
65+
/// Bucket entry: (node_idx, owner_class, exact_name).
66+
type BucketEntry<'a> = (u32, &'a str, &'a str);
67+
let mut buckets: FxHashMap<(String, SchemaType), Vec<BucketEntry<'_>>> = FxHashMap::default();
68+
69+
// Phase 1 — emit SchemaField Nodes + HasProperty edges, populate buckets.
70+
//
71+
// Skip `LocalGraph`s with no `schema_fields` (the majority — most files
72+
// carry no ORM/schema surface). Per-file Owner-class lookup uses
73+
// SymbolTable's per-file index; misses (e.g. extractor emitted a class
74+
// name that SymbolTable doesn't know about, perhaps due to file
75+
// boundary edge cases) silently drop the field. No fabricated edges.
76+
for (lg_idx, local_graph) in local_graphs.iter().enumerate() {
77+
let Some(ref schema_fields) = local_graph.schema_fields else {
78+
continue;
79+
};
80+
if schema_fields.is_empty() {
81+
continue;
82+
}
83+
let path_str = local_graph.file_path.to_string_lossy().replace('\\', "/");
84+
let file_idx = lg_idx as u32;
85+
86+
for raw_sf in schema_fields.iter() {
87+
let owner_name = &*raw_sf.owner_class;
88+
let field_name = &*raw_sf.name;
89+
90+
// Resolve the owning class to an existing Node idx. If the
91+
// SymbolTable doesn't know `owner_name` in this file, the
92+
// class was not parsed as a `Class` / `Struct` / `Trait` /
93+
// `Interface` — likely a generated-code or DSL pattern that
94+
// we don't model. Silently skip rather than emit dangling
95+
// HasProperty.
96+
let Some(class_idx) = symbol_table.lookup_in_file(&path_str, owner_name) else {
97+
continue;
98+
};
99+
100+
// UID: canonical xxh3-64 over (kind, path, owner_class, name).
101+
// owner_class is part of the hash so cross-framework mirrors with
102+
// same (owner, name) but different frameworks get distinct UIDs
103+
// via the field_name component (`framework.field` would also work
104+
// but the canonical helper expects the pure symbol name).
105+
let sf_uid = uid::compute(
106+
NodeKind::SchemaField,
107+
&path_str,
108+
Some(owner_name),
109+
field_name,
110+
);
111+
let name_ref = string_pool.add(field_name);
112+
let owner_ref = string_pool.add(owner_name);
113+
let sf_idx = nodes.len() as u32;
114+
nodes.push(Node {
115+
uid: sf_uid,
116+
name: name_ref,
117+
file_idx,
118+
kind: NodeKind::SchemaField,
119+
span: raw_sf.span,
120+
community_id: 0,
121+
owner_class: owner_ref,
122+
content_hash: 0,
123+
});
124+
125+
// HasProperty: <Class> -> <SchemaField>. Non-heuristic
126+
// (extractor saw an actual `name: T` / `Column(T)` form, so
127+
// the class-owns-this-field claim is structural, not inferred).
128+
edges.push(Edge {
129+
source: class_idx,
130+
target: sf_idx,
131+
rel_type: RelType::HasProperty,
132+
confidence: 1.0,
133+
reason: reason_has_property,
134+
});
135+
136+
// Bucket key: (lowercase_name, type). Lowercase normalizes
137+
// `email` vs `Email`; type-class match keeps `email: str`
138+
// from binding to `email: int`.
139+
buckets
140+
.entry((field_name.to_ascii_lowercase(), raw_sf.type_class))
141+
.or_default()
142+
.push((sf_idx, owner_name, field_name));
143+
}
144+
}
145+
146+
// Phase 2 — pairwise MirrorsField emission within each bucket.
147+
//
148+
// Rubric (per spec line 540 + D3 cluster semantics):
149+
// - exact case-sensitive name match
150+
// - identical SchemaType (granted by bucket key)
151+
// - identical owner-class name (e.g. both "User")
152+
// - bidirectional top-1 (trivial for k=2; D3 covers k≥3 uniform)
153+
//
154+
// Implementation: sub-group bucket by (exact_name, owner_class). Any
155+
// sub-group of size ≥ 2 satisfies all four points and emits pairwise
156+
// `MirrorsField` at heuristic confidence (RelType::MirrorsField is
157+
// listed under `is_heuristic` so default `ecp impact` hides these
158+
// unless `--show-heuristic` is set).
159+
//
160+
// BlindSpot emission for partial matches (3/4) is a documented
161+
// follow-up — see ignored test `test_partial_match_emits_blindspot`.
162+
let mut mirror_count = 0usize;
163+
for entries in buckets.values() {
164+
if entries.len() < 2 {
165+
continue;
166+
}
167+
// Sub-group by (exact_name, owner_class).
168+
let mut sub: FxHashMap<(&str, &str), Vec<u32>> = FxHashMap::default();
169+
for &(idx, owner, name) in entries {
170+
sub.entry((name, owner)).or_default().push(idx);
171+
}
172+
for group in sub.values() {
173+
if group.len() < 2 {
174+
continue;
175+
}
176+
// Pairwise emit. Deterministic order: idx-ascending pairs
177+
// (i, j) where i < j by position in `group`. The vec is
178+
// populated in iteration order from `entries`, which itself
179+
// mirrors LocalGraph iteration order — stable across runs.
180+
for i in 0..group.len() {
181+
for j in (i + 1)..group.len() {
182+
edges.push(Edge {
183+
source: group[i],
184+
target: group[j],
185+
rel_type: RelType::MirrorsField,
186+
confidence: 0.9,
187+
reason: reason_mirror,
188+
});
189+
mirror_count += 1;
190+
}
191+
}
192+
}
193+
}
194+
195+
mirror_count
196+
}

crates/ecp-analyzer/src/protobuf/parser.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ use super::schema_extractors::{
88
};
99
use ecp_core::analyzer::provider::LanguageProvider;
1010
use ecp_core::analyzer::types::{LocalGraph, RawSchemaField};
11-
use ecp_core::pool::StringPool;
1211
use std::path::Path;
1312

1413
pub struct ProtobufProvider;
@@ -28,8 +27,7 @@ impl LanguageProvider for ProtobufProvider {
2827
let text = std::str::from_utf8(source)
2928
.map_err(|e| anyhow::anyhow!("protobuf: UTF-8 decode error in {:?}: {}", path, e))?;
3029

31-
let mut pool = StringPool::new();
32-
let fields = extract_proto_fields(text, &mut pool);
30+
let fields = extract_proto_fields(text);
3331
let schema_fields = (!fields.is_empty()).then(|| fields.into_boxed_slice());
3432

3533
Ok(LocalGraph {
@@ -48,7 +46,7 @@ impl LanguageProvider for ProtobufProvider {
4846
/// - `depth`: brace nesting depth. A top-level `message` bumps depth to 1;
4947
/// any nested `{` (including nested messages, oneofs, options) bumps it
5048
/// further. Fields are only emitted when `depth == 1`.
51-
fn extract_proto_fields(text: &str, pool: &mut StringPool) -> Vec<RawSchemaField> {
49+
fn extract_proto_fields(text: &str) -> Vec<RawSchemaField> {
5250
let mut out: Vec<RawSchemaField> = Vec::new();
5351
let mut current_message: Option<String> = None;
5452
let mut depth: u32 = 0;
@@ -102,9 +100,9 @@ fn extract_proto_fields(text: &str, pool: &mut StringPool) -> Vec<RawSchemaField
102100
let type_class = classify_protobuf_type(type_token);
103101
let span = (row, 0u32, row, line.len() as u32);
104102
out.push(RawSchemaField {
105-
name: pool.add(&field_name),
103+
name: field_name.into_boxed_str(),
106104
type_class,
107-
owner_class: pool.add(owner),
105+
owner_class: owner.clone().into_boxed_str(),
108106
framework: PROTOBUF_FRAMEWORK,
109107
span,
110108
});

0 commit comments

Comments
 (0)