Skip to content

Commit 5625e07

Browse files
userFRMclaude
andcommitted
fix: auto-migrate existing Windows graphs with backslash entity IDs
When loading graphs created on Windows (schema <2.2.0), normalize all backslash entity IDs to forward slashes across entities, file_index, edges, and hierarchy. Preserves all semantic features and lifted data. Bumps schema version to 2.2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e78f3aa commit 5625e07

1 file changed

Lines changed: 146 additions & 3 deletions

File tree

crates/rpg-core/src/schema.rs

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::graph::RPGraph;
88
use anyhow::{Context, Result};
99
use semver::Version;
1010

11-
const CURRENT_VERSION: &str = "2.1.0";
11+
const CURRENT_VERSION: &str = "2.2.0";
1212

1313
/// Validate an RPGraph's schema version using semver compatibility.
1414
///
@@ -40,14 +40,82 @@ pub fn migrate(graph: &mut RPGraph) -> Result<()> {
4040
let found = Version::parse(&graph.version)?;
4141

4242
if found < current {
43-
// Future migrations go here, e.g.:
44-
// if found < Version::new(2, 1, 0) { ... }
43+
// v2.2.0: normalize backslash entity IDs to forward slashes (Windows compat fix)
44+
if found < Version::new(2, 2, 0) {
45+
migrate_normalize_entity_ids(graph);
46+
}
4547
graph.version = CURRENT_VERSION.to_string();
4648
}
4749

4850
Ok(())
4951
}
5052

53+
/// Migrate entity IDs from backslash paths to forward-slash paths.
54+
///
55+
/// On Windows, older graphs stored entity IDs with backslashes
56+
/// (e.g. `src\auth\login.py:validate`). This normalizes them to
57+
/// forward slashes so lookups from MCP tools match consistently.
58+
fn migrate_normalize_entity_ids(graph: &mut RPGraph) {
59+
fn norm(id: &str) -> String {
60+
id.replace('\\', "/")
61+
}
62+
63+
fn fix_hierarchy(
64+
node: &mut crate::graph::HierarchyNode,
65+
remap: &std::collections::HashMap<String, String>,
66+
) {
67+
for id in &mut node.entities {
68+
if let Some(new_id) = remap.get(id.as_str()) {
69+
*id = new_id.clone();
70+
}
71+
}
72+
for child in node.children.values_mut() {
73+
fix_hierarchy(child, remap);
74+
}
75+
}
76+
77+
// 1. Rebuild entities map with normalized keys + id fields
78+
let old_entities = std::mem::take(&mut graph.entities);
79+
let mut id_remap: std::collections::HashMap<String, String> = std::collections::HashMap::new();
80+
for (old_key, mut entity) in old_entities {
81+
let new_key = norm(&old_key);
82+
entity.id = new_key.clone();
83+
if old_key != new_key {
84+
id_remap.insert(old_key, new_key.clone());
85+
}
86+
graph.entities.insert(new_key, entity);
87+
}
88+
89+
// Nothing was remapped — graph already uses forward slashes
90+
if id_remap.is_empty() {
91+
return;
92+
}
93+
94+
// 2. Update file_index value lists
95+
for ids in graph.file_index.values_mut() {
96+
for id in ids.iter_mut() {
97+
if let Some(new_id) = id_remap.get(id.as_str()) {
98+
*id = new_id.clone();
99+
}
100+
}
101+
}
102+
103+
// 3. Update edge source/target
104+
for edge in &mut graph.edges {
105+
if let Some(new_id) = id_remap.get(&edge.source) {
106+
edge.source = new_id.clone();
107+
}
108+
if let Some(new_id) = id_remap.get(&edge.target) {
109+
edge.target = new_id.clone();
110+
}
111+
}
112+
113+
// 4. Update hierarchy entity lists (recursive)
114+
for node in graph.hierarchy.values_mut() {
115+
fix_hierarchy(node, &id_remap);
116+
}
117+
}
118+
51119
/// Serialize an RPGraph to a pretty-printed JSON string.
52120
///
53121
/// Edges are sorted by (source, target, kind) for deterministic output,
@@ -139,4 +207,79 @@ mod tests {
139207
let graph = graph_with_version("not-a-version");
140208
assert!(validate_version(&graph).is_err());
141209
}
210+
211+
#[test]
212+
fn test_migrate_normalizes_backslash_ids() {
213+
use crate::graph::{
214+
DependencyEdge, EdgeKind, Entity, EntityDeps, EntityKind, HierarchyNode,
215+
};
216+
use std::path::PathBuf;
217+
218+
let mut graph = graph_with_version("2.1.0");
219+
220+
// Insert entity with backslash ID (as Windows would produce)
221+
let old_id = r"src\auth\login.py:validate".to_string();
222+
graph.entities.insert(
223+
old_id.clone(),
224+
Entity {
225+
id: old_id.clone(),
226+
kind: EntityKind::Function,
227+
name: "validate".to_string(),
228+
file: PathBuf::from("src/auth/login.py"),
229+
line_start: 1,
230+
line_end: 10,
231+
parent_class: None,
232+
semantic_features: vec!["validates user credentials".to_string()],
233+
feature_source: Some("llm".to_string()),
234+
hierarchy_path: String::new(),
235+
deps: EntityDeps::default(),
236+
signature: None,
237+
},
238+
);
239+
240+
// file_index references the old ID
241+
graph
242+
.file_index
243+
.insert(PathBuf::from("src/auth/login.py"), vec![old_id.clone()]);
244+
245+
// edge references the old ID
246+
graph.edges.push(DependencyEdge {
247+
source: old_id.clone(),
248+
target: "other:target".to_string(),
249+
kind: EdgeKind::Invokes,
250+
});
251+
252+
// hierarchy references the old ID
253+
let mut node = HierarchyNode::new("Auth");
254+
node.entities.push(old_id.clone());
255+
graph.hierarchy.insert("h:Auth".to_string(), node);
256+
257+
// Run migration
258+
migrate(&mut graph).unwrap();
259+
260+
let new_id = "src/auth/login.py:validate";
261+
262+
// Entity moved to new key
263+
assert!(graph.entities.contains_key(new_id));
264+
assert!(!graph.entities.contains_key(&old_id));
265+
assert_eq!(graph.entities[new_id].id, new_id);
266+
// Semantic features preserved
267+
assert_eq!(
268+
graph.entities[new_id].semantic_features,
269+
vec!["validates user credentials"]
270+
);
271+
272+
// file_index updated
273+
let file_ids = &graph.file_index[&PathBuf::from("src/auth/login.py")];
274+
assert_eq!(file_ids, &[new_id]);
275+
276+
// edge updated
277+
assert_eq!(graph.edges[0].source, new_id);
278+
279+
// hierarchy updated
280+
assert_eq!(graph.hierarchy["h:Auth"].entities, vec![new_id]);
281+
282+
// version bumped
283+
assert_eq!(graph.version, CURRENT_VERSION);
284+
}
142285
}

0 commit comments

Comments
 (0)