1use std::collections::HashMap;
25use std::ops::Range;
26use std::sync::Arc;
27
28use rayon::prelude::*;
29
30use crate::graph::unified::edge::delta::{DeltaEdge, DeltaOp};
31use crate::graph::unified::edge::kind::{EdgeKind, MqProtocol};
32use crate::graph::unified::file::FileId;
33use crate::graph::unified::node::NodeId;
34use crate::graph::unified::storage::NodeArena;
35use crate::graph::unified::storage::arena::{NodeEntry, Slot};
36use crate::graph::unified::string::StringId;
37
38use super::pass3_intra::PendingEdge;
39use super::staging::{StagingGraph, StagingOp};
40
41#[derive(Debug, Clone, Default)]
46pub struct GlobalOffsets {
47 pub node_offset: u32,
49 pub string_offset: u32,
51}
52
53#[derive(Debug, Clone)]
55pub struct FilePlan {
56 pub parsed_index: usize,
58 pub file_id: FileId,
60 pub node_range: Range<u32>,
62 pub string_range: Range<u32>,
64}
65
66#[derive(Debug, Clone)]
68pub struct ChunkCommitPlan {
69 pub file_plans: Vec<FilePlan>,
71 pub total_nodes: u32,
73 pub total_strings: u32,
75 pub total_edges: u64,
77}
78
79#[must_use]
99pub fn compute_commit_plan(
100 node_counts: &[u32],
101 string_counts: &[u32],
102 edge_counts: &[u32],
103 file_ids: &[FileId],
104 node_offset: u32,
105 string_offset: u32,
106) -> ChunkCommitPlan {
107 debug_assert_eq!(node_counts.len(), string_counts.len());
108 debug_assert_eq!(node_counts.len(), edge_counts.len());
109 debug_assert_eq!(node_counts.len(), file_ids.len());
110
111 let mut plans = Vec::with_capacity(node_counts.len());
112 let mut node_cursor = node_offset;
113 let mut string_cursor = string_offset;
114 let mut total_edges: u64 = 0;
115
116 for i in 0..node_counts.len() {
117 let nc = node_counts[i];
118 let sc = string_counts[i];
119
120 let node_end = node_cursor
121 .checked_add(nc)
122 .expect("node ID space overflow in commit plan");
123 let string_end = string_cursor
124 .checked_add(sc)
125 .expect("string ID space overflow in commit plan");
126
127 plans.push(FilePlan {
128 parsed_index: i,
129 file_id: file_ids[i],
130 node_range: node_cursor..node_end,
131 string_range: string_cursor..string_end,
132 });
133
134 node_cursor = node_end;
135 string_cursor = string_end;
136 total_edges += u64::from(edge_counts[i]);
137 }
138
139 ChunkCommitPlan {
140 file_plans: plans,
141 total_nodes: node_cursor - node_offset,
142 total_strings: string_cursor - string_offset,
143 total_edges,
144 }
145}
146
147#[must_use]
152pub fn phase2_assign_ranges(
153 staging_graphs: &[&StagingGraph],
154 file_ids: &[FileId],
155 offsets: &GlobalOffsets,
156) -> ChunkCommitPlan {
157 let node_counts: Vec<u32> = staging_graphs
158 .iter()
159 .map(|sg| sg.node_count_u32())
160 .collect();
161 let string_counts: Vec<u32> = staging_graphs
162 .iter()
163 .map(|sg| sg.string_count_u32())
164 .collect();
165 let edge_counts: Vec<u32> = staging_graphs
166 .iter()
167 .map(|sg| sg.edge_count_u32())
168 .collect();
169
170 compute_commit_plan(
171 &node_counts,
172 &string_counts,
173 &edge_counts,
174 file_ids,
175 offsets.node_offset,
176 offsets.string_offset,
177 )
178}
179
180pub struct Phase3Result {
183 pub per_file_edges: Vec<Vec<PendingEdge>>,
185 pub per_file_node_ids: Vec<Vec<NodeId>>,
194 pub total_nodes_written: usize,
196 pub total_strings_written: usize,
198 pub total_edges_collected: usize,
200}
201
202#[must_use]
236pub(crate) fn phase3_parallel_commit<
237 G: crate::graph::unified::mutation_target::GraphMutationTarget,
238>(
239 plan: &ChunkCommitPlan,
240 staging_graphs: &[&StagingGraph],
241 graph: &mut G,
242) -> Phase3Result {
243 if plan.file_plans.is_empty() {
244 return Phase3Result {
245 per_file_edges: Vec::new(),
246 per_file_node_ids: Vec::new(),
247 total_nodes_written: 0,
248 total_strings_written: 0,
249 total_edges_collected: 0,
250 };
251 }
252
253 let node_start = plan.file_plans[0].node_range.start;
255 let string_start = plan.file_plans[0].string_range.start;
256
257 let (arena, interner) = graph.nodes_and_strings_mut();
262
263 let node_slice = arena.bulk_slice_mut(node_start, plan.total_nodes);
265 let (str_slice, rc_slice) = interner.bulk_slices_mut(string_start, plan.total_strings);
266
267 let mut node_remaining = &mut *node_slice;
269 let mut str_remaining = &mut *str_slice;
270 let mut rc_remaining = &mut *rc_slice;
271
272 #[allow(clippy::type_complexity)]
273 let mut file_work: Vec<(
274 &mut [Slot<NodeEntry>],
275 &mut [Option<Arc<str>>],
276 &mut [u32],
277 &FilePlan,
278 usize,
279 )> = Vec::with_capacity(plan.file_plans.len());
280
281 for (i, file_plan) in plan.file_plans.iter().enumerate() {
282 let nc = (file_plan.node_range.end - file_plan.node_range.start) as usize;
283 let sc = (file_plan.string_range.end - file_plan.string_range.start) as usize;
284
285 let (n, nr) = node_remaining.split_at_mut(nc);
286 let (s, sr) = str_remaining.split_at_mut(sc);
287 let (r, rr) = rc_remaining.split_at_mut(sc);
288
289 file_work.push((n, s, r, file_plan, i));
290 node_remaining = nr;
291 str_remaining = sr;
292 rc_remaining = rr;
293 }
294
295 let results: Vec<FileCommitResult> = file_work
297 .into_par_iter()
298 .map(|(node_slots, str_slots, rc_slots, file_plan, idx)| {
299 commit_single_file(
300 staging_graphs[idx],
301 file_plan,
302 node_slots,
303 str_slots,
304 rc_slots,
305 )
306 })
307 .collect();
308
309 let total_nodes_written: usize = results.iter().map(|r| r.nodes_written).sum();
310 let total_strings_written: usize = results.iter().map(|r| r.strings_written).sum();
311 let total_edges_collected: usize = results.iter().map(|r| r.edges.len()).sum();
312 let mut per_file_edges = Vec::with_capacity(results.len());
313 let mut per_file_node_ids = Vec::with_capacity(results.len());
314 for r in results {
315 per_file_edges.push(r.edges);
316 per_file_node_ids.push(r.node_ids);
317 }
318
319 Phase3Result {
320 per_file_edges,
321 per_file_node_ids,
322 total_nodes_written,
323 total_strings_written,
324 total_edges_collected,
325 }
326}
327
328struct FileCommitResult {
344 edges: Vec<PendingEdge>,
345 node_ids: Vec<NodeId>,
349 nodes_written: usize,
350 strings_written: usize,
351}
352
353fn commit_single_file(
354 staging: &StagingGraph,
355 plan: &FilePlan,
356 node_slots: &mut [Slot<NodeEntry>],
357 str_slots: &mut [Option<Arc<str>>],
358 rc_slots: &mut [u32],
359) -> FileCommitResult {
360 let ops = staging.operations();
361
362 let (string_remap, strings_written) = write_strings(ops, plan, str_slots, rc_slots);
364
365 let (node_remap, nodes_written, node_ids) = write_nodes(ops, plan, node_slots, &string_remap);
367
368 let edges = collect_edges(ops, plan, &node_remap, &string_remap);
370
371 FileCommitResult {
372 edges,
373 node_ids,
374 nodes_written,
375 strings_written,
376 }
377}
378
379fn write_strings(
386 ops: &[StagingOp],
387 plan: &FilePlan,
388 str_slots: &mut [Option<Arc<str>>],
389 rc_slots: &mut [u32],
390) -> (HashMap<StringId, StringId>, usize) {
391 let mut remap = HashMap::new();
392 let mut string_cursor = 0usize;
393
394 for op in ops {
395 if let StagingOp::InternString { local_id, value } = op {
396 assert!(
398 local_id.is_local(),
399 "non-local StringId {:?} in InternString op for file {:?}",
400 local_id,
401 plan.file_id,
402 );
403 assert!(
405 !remap.contains_key(local_id),
406 "duplicate local StringId {:?} in InternString op for file {:?}",
407 local_id,
408 plan.file_id,
409 );
410
411 if string_cursor >= str_slots.len() {
412 log::warn!(
413 "string slot overflow in file {:?}: cursor={string_cursor}, slots={}, skipping remaining strings",
414 plan.file_id,
415 str_slots.len()
416 );
417 break;
418 }
419
420 #[allow(clippy::cast_possible_truncation)] let global_id = StringId::new(plan.string_range.start + string_cursor as u32);
423
424 str_slots[string_cursor] = Some(Arc::from(value.as_str()));
426 rc_slots[string_cursor] = 1;
427
428 remap.insert(*local_id, global_id);
429 string_cursor += 1;
430 }
431 }
432
433 (remap, string_cursor)
434}
435
436fn remap_node_entry_string_ids(entry: &mut NodeEntry, remap: &HashMap<StringId, StringId>) {
442 remap_required_local(&mut entry.name, remap);
443 remap_option_local(&mut entry.signature, remap);
444 remap_option_local(&mut entry.doc, remap);
445 remap_option_local(&mut entry.qualified_name, remap);
446 remap_option_local(&mut entry.visibility, remap);
447}
448
449#[allow(clippy::match_same_arms)]
454fn remap_edge_kind_local_string_ids(kind: &mut EdgeKind, remap: &HashMap<StringId, StringId>) {
455 match kind {
456 EdgeKind::Imports { alias, .. } => remap_option_local(alias, remap),
457 EdgeKind::Exports { alias, .. } => remap_option_local(alias, remap),
458 EdgeKind::TypeOf { name, .. } => remap_option_local(name, remap),
459 EdgeKind::TraitMethodBinding {
460 trait_name,
461 impl_type,
462 ..
463 } => {
464 remap_required_local(trait_name, remap);
465 remap_required_local(impl_type, remap);
466 }
467 EdgeKind::HttpRequest { url, .. } => remap_option_local(url, remap),
468 EdgeKind::GrpcCall { service, method } => {
469 remap_required_local(service, remap);
470 remap_required_local(method, remap);
471 }
472 EdgeKind::DbQuery { table, .. } => remap_option_local(table, remap),
473 EdgeKind::TableRead { table_name, schema } => {
474 remap_required_local(table_name, remap);
475 remap_option_local(schema, remap);
476 }
477 EdgeKind::TableWrite {
478 table_name, schema, ..
479 } => {
480 remap_required_local(table_name, remap);
481 remap_option_local(schema, remap);
482 }
483 EdgeKind::TriggeredBy {
484 trigger_name,
485 schema,
486 } => {
487 remap_required_local(trigger_name, remap);
488 remap_option_local(schema, remap);
489 }
490 EdgeKind::MessageQueue { protocol, topic } => {
491 if let MqProtocol::Other(s) = protocol {
492 remap_required_local(s, remap);
493 }
494 remap_option_local(topic, remap);
495 }
496 EdgeKind::WebSocket { event } => remap_option_local(event, remap),
497 EdgeKind::GraphQLOperation { operation } => remap_required_local(operation, remap),
498 EdgeKind::ProcessExec { command } => remap_required_local(command, remap),
499 EdgeKind::FileIpc { path_pattern } => remap_option_local(path_pattern, remap),
500 EdgeKind::ProtocolCall { protocol, metadata } => {
501 remap_required_local(protocol, remap);
502 remap_option_local(metadata, remap);
503 }
504 EdgeKind::Defines
506 | EdgeKind::Contains
507 | EdgeKind::Calls { .. }
508 | EdgeKind::References
509 | EdgeKind::Inherits
510 | EdgeKind::Implements
511 | EdgeKind::LifetimeConstraint { .. }
512 | EdgeKind::MacroExpansion { .. }
513 | EdgeKind::FfiCall { .. }
514 | EdgeKind::WebAssemblyCall
515 | EdgeKind::GenericBound
516 | EdgeKind::AnnotatedWith
517 | EdgeKind::AnnotationParam
518 | EdgeKind::LambdaCaptures
519 | EdgeKind::ModuleExports
520 | EdgeKind::ModuleRequires
521 | EdgeKind::ModuleOpens
522 | EdgeKind::ModuleProvides
523 | EdgeKind::TypeArgument
524 | EdgeKind::ExtensionReceiver
525 | EdgeKind::CompanionOf
526 | EdgeKind::SealedPermit => {}
527 }
528}
529
530fn remap_required_local(id: &mut StringId, remap: &HashMap<StringId, StringId>) {
535 if id.is_local() {
536 let global = remap.get(id).unwrap_or_else(|| {
537 panic!("unmapped local StringId {id:?} — missing intern_string op?")
538 });
539 *id = *global;
540 }
541}
542
543fn remap_option_local(opt: &mut Option<StringId>, remap: &HashMap<StringId, StringId>) {
545 if let Some(id) = opt
546 && id.is_local()
547 {
548 let global = remap.get(id).unwrap_or_else(|| {
549 panic!("unmapped local StringId {id:?} — missing intern_string op?")
550 });
551 *id = *global;
552 }
553}
554
555fn write_nodes(
561 ops: &[StagingOp],
562 plan: &FilePlan,
563 node_slots: &mut [Slot<NodeEntry>],
564 string_remap: &HashMap<StringId, StringId>,
565) -> (HashMap<NodeId, NodeId>, usize, Vec<NodeId>) {
566 let mut node_remap = HashMap::new();
567 let mut node_cursor = 0usize;
568 let mut node_ids: Vec<NodeId> = Vec::with_capacity(node_slots.len());
569
570 for op in ops {
571 if let StagingOp::AddNode {
572 entry, expected_id, ..
573 } = op
574 {
575 if node_cursor >= node_slots.len() {
576 log::warn!(
577 "node slot overflow in file {:?}: cursor={node_cursor}, slots={}, skipping remaining nodes",
578 plan.file_id,
579 node_slots.len()
580 );
581 break;
582 }
583
584 let mut entry = entry.clone();
585
586 remap_node_entry_string_ids(&mut entry, string_remap);
588
589 entry.file = plan.file_id;
591
592 #[allow(clippy::cast_possible_truncation)] let actual_index = plan.node_range.start + node_cursor as u32;
595 let actual_id = NodeId::new(actual_index, 1);
596
597 node_slots[node_cursor] = Slot::new_occupied(1, entry);
599
600 if let Some(expected) = expected_id {
601 node_remap.insert(*expected, actual_id);
602 }
603
604 node_ids.push(actual_id);
605 node_cursor += 1;
606 }
607 }
608
609 (node_remap, node_cursor, node_ids)
610}
611
612fn collect_edges(
615 ops: &[StagingOp],
616 plan: &FilePlan,
617 node_remap: &HashMap<NodeId, NodeId>,
618 string_remap: &HashMap<StringId, StringId>,
619) -> Vec<PendingEdge> {
620 let mut edges = Vec::new();
621
622 for op in ops {
623 if let StagingOp::AddEdge {
624 source,
625 target,
626 kind,
627 spans,
628 ..
629 } = op
630 {
631 let actual_source = node_remap.get(source).copied().unwrap_or(*source);
632 let actual_target = node_remap.get(target).copied().unwrap_or(*target);
633
634 let mut remapped_kind = kind.clone();
636 remap_edge_kind_local_string_ids(&mut remapped_kind, string_remap);
637
638 edges.push(PendingEdge {
639 source: actual_source,
640 target: actual_target,
641 kind: remapped_kind,
642 file: plan.file_id,
643 spans: spans.clone(),
644 });
645 }
646 }
647
648 edges
649}
650
651#[allow(clippy::implicit_hasher)]
656pub fn remap_string_id(id: &mut StringId, remap: &HashMap<StringId, StringId>) {
657 if let Some(&canonical) = remap.get(id) {
658 *id = canonical;
659 }
660}
661
662#[allow(clippy::implicit_hasher)]
664pub fn remap_option_string_id(id: &mut Option<StringId>, remap: &HashMap<StringId, StringId>) {
665 if let Some(inner) = id {
666 remap_string_id(inner, remap);
667 }
668}
669
670#[allow(clippy::match_same_arms, clippy::implicit_hasher)] pub fn remap_edge_kind_string_ids(kind: &mut EdgeKind, remap: &HashMap<StringId, StringId>) {
676 match kind {
677 EdgeKind::Imports { alias, .. } => remap_option_string_id(alias, remap),
679 EdgeKind::Exports { alias, .. } => remap_option_string_id(alias, remap),
680 EdgeKind::TypeOf { name, .. } => remap_option_string_id(name, remap),
681 EdgeKind::TraitMethodBinding {
682 trait_name,
683 impl_type,
684 ..
685 } => {
686 remap_string_id(trait_name, remap);
687 remap_string_id(impl_type, remap);
688 }
689 EdgeKind::HttpRequest { url, .. } => remap_option_string_id(url, remap),
690 EdgeKind::GrpcCall { service, method } => {
691 remap_string_id(service, remap);
692 remap_string_id(method, remap);
693 }
694 EdgeKind::DbQuery { table, .. } => remap_option_string_id(table, remap),
695 EdgeKind::TableRead { table_name, schema } => {
696 remap_string_id(table_name, remap);
697 remap_option_string_id(schema, remap);
698 }
699 EdgeKind::TableWrite {
700 table_name, schema, ..
701 } => {
702 remap_string_id(table_name, remap);
703 remap_option_string_id(schema, remap);
704 }
705 EdgeKind::TriggeredBy {
706 trigger_name,
707 schema,
708 } => {
709 remap_string_id(trigger_name, remap);
710 remap_option_string_id(schema, remap);
711 }
712 EdgeKind::MessageQueue { protocol, topic } => {
713 if let MqProtocol::Other(s) = protocol {
714 remap_string_id(s, remap);
715 }
716 remap_option_string_id(topic, remap);
717 }
718 EdgeKind::WebSocket { event } => remap_option_string_id(event, remap),
719 EdgeKind::GraphQLOperation { operation } => remap_string_id(operation, remap),
720 EdgeKind::ProcessExec { command } => remap_string_id(command, remap),
721 EdgeKind::FileIpc { path_pattern } => remap_option_string_id(path_pattern, remap),
722 EdgeKind::ProtocolCall { protocol, metadata } => {
723 remap_string_id(protocol, remap);
724 remap_option_string_id(metadata, remap);
725 }
726 EdgeKind::Defines
728 | EdgeKind::Contains
729 | EdgeKind::Calls { .. }
730 | EdgeKind::References
731 | EdgeKind::Inherits
732 | EdgeKind::Implements
733 | EdgeKind::LifetimeConstraint { .. }
734 | EdgeKind::MacroExpansion { .. }
735 | EdgeKind::FfiCall { .. }
736 | EdgeKind::WebAssemblyCall
737 | EdgeKind::GenericBound
738 | EdgeKind::AnnotatedWith
739 | EdgeKind::AnnotationParam
740 | EdgeKind::LambdaCaptures
741 | EdgeKind::ModuleExports
742 | EdgeKind::ModuleRequires
743 | EdgeKind::ModuleOpens
744 | EdgeKind::ModuleProvides
745 | EdgeKind::TypeArgument
746 | EdgeKind::ExtensionReceiver
747 | EdgeKind::CompanionOf
748 | EdgeKind::SealedPermit => {}
749 }
750}
751
752#[allow(clippy::implicit_hasher)]
759pub fn remap_node_entry_global(entry: &mut NodeEntry, remap: &HashMap<StringId, StringId>) {
760 remap_string_id(&mut entry.name, remap);
761 remap_option_string_id(&mut entry.signature, remap);
762 remap_option_string_id(&mut entry.doc, remap);
763 remap_option_string_id(&mut entry.qualified_name, remap);
764 remap_option_string_id(&mut entry.visibility, remap);
765}
766
767#[allow(clippy::implicit_hasher)]
774pub fn phase4_apply_global_remap(
775 arena: &mut NodeArena,
776 all_edges: &mut [Vec<PendingEdge>],
777 remap: &HashMap<StringId, StringId>,
778) {
779 if remap.is_empty() {
780 return;
781 }
782
783 for (_id, entry) in arena.iter_mut() {
785 remap_node_entry_global(entry, remap);
786 }
787
788 for file_edges in all_edges.iter_mut() {
790 for edge in file_edges.iter_mut() {
791 remap_edge_kind_string_ids(&mut edge.kind, remap);
792 }
793 }
794}
795
796#[derive(Debug, Default)]
798pub struct UnificationStats {
799 pub candidate_pairs_examined: usize,
801 pub nodes_merged: usize,
803 pub edges_rewritten: usize,
805 pub nodes_inert: usize,
807 pub elapsed_ms: u64,
809}
810
811pub(crate) fn phase4c_prime_unify_cross_file_nodes<
842 G: crate::graph::unified::mutation_target::GraphMutationTarget,
843>(
844 graph: &mut G,
845 all_edges: &mut [Vec<PendingEdge>],
846) -> UnificationStats {
847 use crate::graph::unified::mutation_target::GraphMutationTarget;
848
849 use super::helper::CALL_COMPATIBLE_KINDS;
850 use super::unification::{NodeRemapTable, merge_node_into};
851 use std::time::Instant;
852
853 let start = Instant::now();
854 let mut stats = UnificationStats::default();
855
856 let mut qn_groups: HashMap<crate::graph::unified::string::StringId, Vec<NodeId>> =
859 HashMap::new();
860
861 for (node_id, entry) in GraphMutationTarget::nodes(graph).iter() {
862 if !CALL_COMPATIBLE_KINDS.contains(&entry.kind) {
863 continue;
864 }
865 if let Some(qn_id) = entry.qualified_name {
866 qn_groups.entry(qn_id).or_default().push(node_id);
867 }
868 }
869
870 let groups_to_unify: Vec<Vec<NodeId>> = qn_groups
872 .into_values()
873 .filter(|group| {
874 if group.len() >= 2 {
875 stats.candidate_pairs_examined += 1;
876 true
877 } else {
878 false
879 }
880 })
881 .collect();
882
883 let mut remap = NodeRemapTable::with_capacity(groups_to_unify.len());
885
886 let path_keys: HashMap<NodeId, String> = {
905 let arena = GraphMutationTarget::nodes(graph);
906 let files = GraphMutationTarget::files(graph);
907 let mut out: HashMap<NodeId, String> =
908 HashMap::with_capacity(groups_to_unify.iter().map(Vec::len).sum());
909 for group in &groups_to_unify {
910 for &nid in group {
911 if out.contains_key(&nid) {
912 continue;
913 }
914 let key = arena
915 .get(nid)
916 .and_then(|entry| files.resolve(entry.file))
917 .map_or_else(String::new, |path| path.to_string_lossy().into_owned());
918 out.insert(nid, key);
919 }
920 }
921 out
922 };
923 let empty_path_key = String::new();
924
925 for group in &groups_to_unify {
926 let winner_id = *group
930 .iter()
931 .max_by(|&&a, &&b| {
932 let ea = GraphMutationTarget::nodes(graph).get(a);
933 let eb = GraphMutationTarget::nodes(graph).get(b);
934 match (ea, eb) {
935 (Some(ea), Some(eb)) => {
936 let a_real = ea.start_line > 0;
938 let b_real = eb.start_line > 0;
939 match (a_real, b_real) {
940 (true, false) => std::cmp::Ordering::Greater,
941 (false, true) => std::cmp::Ordering::Less,
942 _ => {
943 let a_range = ea.end_line.saturating_sub(ea.start_line);
945 let b_range = eb.end_line.saturating_sub(eb.start_line);
946 a_range
947 .cmp(&b_range)
948 .then_with(|| {
949 let pa = path_keys.get(&a).unwrap_or(&empty_path_key);
954 let pb = path_keys.get(&b).unwrap_or(&empty_path_key);
955 pb.cmp(pa)
956 })
957 .then_with(|| {
958 b.index().cmp(&a.index())
963 })
964 }
965 }
966 }
967 (Some(_), None) => std::cmp::Ordering::Greater,
968 (None, Some(_)) => std::cmp::Ordering::Less,
969 (None, None) => std::cmp::Ordering::Equal,
970 }
971 })
972 .expect("group is non-empty");
973
974 for &node_id in group {
976 if node_id == winner_id {
977 continue;
978 }
979 match merge_node_into(GraphMutationTarget::nodes_mut(graph), node_id, winner_id) {
980 Ok(()) => {
981 remap.insert(node_id, winner_id);
982 stats.nodes_merged += 1;
983 stats.nodes_inert += 1;
984 }
985 Err(e) => {
986 log::debug!("Phase 4c-prime: skipping merge ({e})");
987 }
988 }
989 }
990 }
991
992 if !remap.is_empty() {
1009 let pre_count: usize = all_edges.iter().map(|v| v.len()).sum();
1010 remap.apply_to_edges(all_edges);
1011 remap.apply_to_committed_edges(GraphMutationTarget::edges(graph));
1012 stats.edges_rewritten = pre_count; }
1061
1062 stats.elapsed_ms = start.elapsed().as_millis() as u64;
1063 stats
1064}
1065
1066#[must_use]
1073pub fn pending_edges_to_delta(
1074 per_file_edges: &[Vec<PendingEdge>],
1075 seq_start: u64,
1076) -> (Vec<Vec<DeltaEdge>>, u64) {
1077 let mut seq = seq_start;
1078 let mut result = Vec::with_capacity(per_file_edges.len());
1079
1080 for file_edges in per_file_edges {
1081 let mut delta_vec = Vec::with_capacity(file_edges.len());
1082 for edge in file_edges {
1083 delta_vec.push(DeltaEdge::with_spans(
1084 edge.source,
1085 edge.target,
1086 edge.kind.clone(),
1087 seq,
1088 DeltaOp::Add,
1089 edge.file,
1090 edge.spans.clone(),
1091 ));
1092 seq += 1;
1093 }
1094 result.push(delta_vec);
1095 }
1096
1097 (result, seq)
1098}
1099
1100pub(crate) fn rebuild_indices<G: crate::graph::unified::mutation_target::GraphMutationTarget>(
1118 graph: &mut G,
1119) {
1120 let (nodes, indices) = graph.nodes_and_indices_mut();
1121 indices.build_from_arena(nodes);
1122}
1123
1124pub(crate) fn phase4d_bulk_insert_edges<
1154 G: crate::graph::unified::mutation_target::GraphMutationTarget,
1155>(
1156 graph: &mut G,
1157 per_file_edges: &[Vec<PendingEdge>],
1158) -> u64 {
1159 let edge_seq_start = graph.edges().forward().seq_counter();
1163 let (delta_edge_vecs, final_seq) = pending_edges_to_delta(per_file_edges, edge_seq_start);
1164 let total_edge_count: u64 = delta_edge_vecs.iter().map(|v| v.len() as u64).sum();
1165 if total_edge_count > 0 {
1166 graph
1167 .edges_mut()
1168 .add_edges_bulk_ordered(&delta_edge_vecs, total_edge_count);
1169 }
1170 final_seq
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176
1177 #[test]
1178 fn test_compute_commit_plan_basic() {
1179 let file_ids = vec![FileId::new(0), FileId::new(1), FileId::new(2)];
1180 let node_counts = vec![3, 0, 5];
1181 let string_counts = vec![2, 1, 3];
1182 let edge_counts = vec![4, 0, 6];
1183
1184 let plan = compute_commit_plan(
1185 &node_counts,
1186 &string_counts,
1187 &edge_counts,
1188 &file_ids,
1189 0,
1190 1, );
1192
1193 assert_eq!(plan.total_nodes, 8);
1194 assert_eq!(plan.total_strings, 6);
1195 assert_eq!(plan.total_edges, 10);
1196
1197 assert_eq!(plan.file_plans[0].node_range, 0..3);
1199 assert_eq!(plan.file_plans[0].string_range, 1..3);
1200
1201 assert_eq!(plan.file_plans[1].node_range, 3..3);
1203 assert_eq!(plan.file_plans[1].string_range, 3..4);
1204
1205 assert_eq!(plan.file_plans[2].node_range, 3..8);
1207 assert_eq!(plan.file_plans[2].string_range, 4..7);
1208 }
1209
1210 #[test]
1211 fn test_compute_commit_plan_with_offsets() {
1212 let file_ids = vec![FileId::new(5)];
1213 let plan = compute_commit_plan(&[10], &[5], &[7], &file_ids, 100, 50);
1214 assert_eq!(plan.file_plans[0].node_range, 100..110);
1215 assert_eq!(plan.file_plans[0].string_range, 50..55);
1216 assert_eq!(plan.total_nodes, 10);
1217 assert_eq!(plan.total_strings, 5);
1218 assert_eq!(plan.total_edges, 7);
1219 }
1220
1221 #[test]
1222 fn test_compute_commit_plan_empty() {
1223 let plan = compute_commit_plan(&[], &[], &[], &[], 0, 1);
1224 assert_eq!(plan.total_nodes, 0);
1225 assert_eq!(plan.total_strings, 0);
1226 assert_eq!(plan.total_edges, 0);
1227 assert!(plan.file_plans.is_empty());
1228 }
1229
1230 #[test]
1231 fn test_remap_string_id_basic() {
1232 let mut remap = HashMap::new();
1233 remap.insert(StringId::new(1), StringId::new(100));
1234
1235 let mut id = StringId::new(1);
1236 remap_string_id(&mut id, &remap);
1237 assert_eq!(id, StringId::new(100));
1238 }
1239
1240 #[test]
1241 fn test_remap_string_id_not_in_remap() {
1242 let remap = HashMap::new();
1243 let mut id = StringId::new(42);
1244 remap_string_id(&mut id, &remap);
1245 assert_eq!(id, StringId::new(42)); }
1247
1248 #[test]
1249 fn test_remap_option_string_id() {
1250 let mut remap = HashMap::new();
1251 remap.insert(StringId::new(5), StringId::new(50));
1252
1253 let mut some_id = Some(StringId::new(5));
1254 remap_option_string_id(&mut some_id, &remap);
1255 assert_eq!(some_id, Some(StringId::new(50)));
1256
1257 let mut none_id: Option<StringId> = None;
1258 remap_option_string_id(&mut none_id, &remap);
1259 assert_eq!(none_id, None);
1260 }
1261
1262 #[test]
1263 fn test_remap_edge_kind_imports() {
1264 let mut remap = HashMap::new();
1265 remap.insert(StringId::new(1), StringId::new(100));
1266
1267 let mut kind = EdgeKind::Imports {
1268 alias: Some(StringId::new(1)),
1269 is_wildcard: false,
1270 };
1271 remap_edge_kind_string_ids(&mut kind, &remap);
1272 assert!(
1273 matches!(kind, EdgeKind::Imports { alias: Some(id), .. } if id == StringId::new(100))
1274 );
1275 }
1276
1277 #[test]
1278 fn test_remap_edge_kind_trait_method_binding() {
1279 let mut remap = HashMap::new();
1280 remap.insert(StringId::new(1), StringId::new(100));
1281 remap.insert(StringId::new(2), StringId::new(200));
1282
1283 let mut kind = EdgeKind::TraitMethodBinding {
1284 trait_name: StringId::new(1),
1285 impl_type: StringId::new(2),
1286 is_ambiguous: false,
1287 };
1288 remap_edge_kind_string_ids(&mut kind, &remap);
1289 assert!(
1290 matches!(kind, EdgeKind::TraitMethodBinding { trait_name, impl_type, .. }
1291 if trait_name == StringId::new(100) && impl_type == StringId::new(200))
1292 );
1293 }
1294
1295 #[test]
1296 fn test_remap_edge_kind_no_op_variants() {
1297 let remap = HashMap::new();
1298
1299 let mut kind = EdgeKind::Defines;
1301 remap_edge_kind_string_ids(&mut kind, &remap);
1302 assert!(matches!(kind, EdgeKind::Defines));
1303
1304 let mut kind = EdgeKind::Calls {
1306 argument_count: 3,
1307 is_async: true,
1308 };
1309 remap_edge_kind_string_ids(&mut kind, &remap);
1310 assert!(matches!(
1311 kind,
1312 EdgeKind::Calls {
1313 argument_count: 3,
1314 is_async: true,
1315 }
1316 ));
1317 }
1318
1319 fn placeholder_entry() -> NodeEntry {
1320 use crate::graph::unified::node::NodeKind;
1321 NodeEntry::new(NodeKind::Function, StringId::new(0), FileId::new(0))
1322 }
1323
1324 #[test]
1325 fn test_phase2_assign_ranges_basic() {
1326 use super::super::staging::StagingGraph;
1327
1328 let mut sg0 = StagingGraph::new();
1330 let mut sg1 = StagingGraph::new();
1331
1332 let entry0 = placeholder_entry();
1334 let n0 = sg0.add_node(entry0.clone());
1335 let n1 = sg0.add_node(entry0.clone());
1336 sg0.intern_string(StringId::new_local(0), "hello".into());
1337 sg0.add_edge(
1338 n0,
1339 n1,
1340 EdgeKind::Calls {
1341 argument_count: 0,
1342 is_async: false,
1343 },
1344 FileId::new(0),
1345 );
1346
1347 sg1.add_node(entry0);
1349 sg1.intern_string(StringId::new_local(0), "world".into());
1350 sg1.intern_string(StringId::new_local(1), "foo".into());
1351
1352 let file_ids = vec![FileId::new(10), FileId::new(11)];
1353 let offsets = GlobalOffsets {
1354 node_offset: 5,
1355 string_offset: 3,
1356 };
1357
1358 let plan = phase2_assign_ranges(&[&sg0, &sg1], &file_ids, &offsets);
1359
1360 assert_eq!(plan.file_plans[0].node_range, 5..7);
1362 assert_eq!(plan.file_plans[0].string_range, 3..4);
1363
1364 assert_eq!(plan.file_plans[1].node_range, 7..8);
1366 assert_eq!(plan.file_plans[1].string_range, 4..6);
1367
1368 assert_eq!(plan.total_nodes, 3);
1369 assert_eq!(plan.total_strings, 3);
1370 assert_eq!(plan.total_edges, 1);
1371 }
1372
1373 #[test]
1374 fn test_phase3_parallel_commit_basic() {
1375 use super::super::staging::StagingGraph;
1376 use crate::graph::unified::concurrent::CodeGraph;
1377 use crate::graph::unified::node::NodeKind;
1378 let mut sg = StagingGraph::new();
1387 let local_name = StringId::new_local(0);
1388 sg.intern_string(local_name, "my_func".into());
1389
1390 let entry = NodeEntry::new(NodeKind::Function, local_name, FileId::new(0));
1391 let n0 = sg.add_node(entry.clone());
1392
1393 let entry2 = NodeEntry::new(NodeKind::Variable, local_name, FileId::new(0));
1394 let n1 = sg.add_node(entry2);
1395
1396 sg.add_edge(
1397 n0,
1398 n1,
1399 EdgeKind::Calls {
1400 argument_count: 0,
1401 is_async: false,
1402 },
1403 FileId::new(0),
1404 );
1405
1406 let file_ids = vec![FileId::new(5)];
1407
1408 let mut graph = CodeGraph::new();
1412 graph
1413 .nodes_mut()
1414 .alloc_range(10, &placeholder_entry())
1415 .unwrap();
1416 let string_start = graph.strings_mut().alloc_range(1).unwrap();
1417 assert_eq!(string_start, 1); let offsets = GlobalOffsets {
1420 node_offset: 10, string_offset: string_start,
1422 };
1423 let plan = phase2_assign_ranges(&[&sg], &file_ids, &offsets);
1424 assert_eq!(plan.file_plans[0].node_range, 10..12);
1425
1426 graph
1428 .nodes_mut()
1429 .alloc_range(plan.total_nodes, &placeholder_entry())
1430 .unwrap();
1431 graph.strings_mut().alloc_range(plan.total_strings).unwrap();
1432
1433 let result = phase3_parallel_commit(&plan, &[&sg], &mut graph);
1436
1437 assert_eq!(result.total_nodes_written, 2);
1439 assert_eq!(result.total_strings_written, 1);
1440
1441 let global_name = StringId::new(string_start);
1443 assert_eq!(&*graph.strings().resolve(global_name).unwrap(), "my_func");
1444
1445 assert_eq!(result.per_file_edges.len(), 1);
1447 assert_eq!(result.per_file_edges[0].len(), 1);
1448
1449 let edge = &result.per_file_edges[0][0];
1451 assert_eq!(edge.file, FileId::new(5));
1452 assert_eq!(edge.source, NodeId::new(10, 1)); assert_eq!(edge.target, NodeId::new(11, 1)); assert_eq!(result.per_file_node_ids.len(), 1);
1459 assert_eq!(
1460 result.per_file_node_ids[0],
1461 vec![NodeId::new(10, 1), NodeId::new(11, 1)]
1462 );
1463 }
1464
1465 #[test]
1466 fn test_phase3_parallel_commit_empty() {
1467 use crate::graph::unified::concurrent::CodeGraph;
1468
1469 let mut graph = CodeGraph::new();
1470
1471 let plan = ChunkCommitPlan {
1472 file_plans: vec![],
1473 total_nodes: 0,
1474 total_strings: 0,
1475 total_edges: 0,
1476 };
1477
1478 let result = phase3_parallel_commit(&plan, &[], &mut graph);
1479 assert!(result.per_file_edges.is_empty());
1480 assert!(result.per_file_node_ids.is_empty());
1481 assert_eq!(result.total_nodes_written, 0);
1482 assert_eq!(result.total_strings_written, 0);
1483 }
1484
1485 #[test]
1498 #[cfg(feature = "rebuild-internals")]
1499 fn phase3_parallel_commit_runs_against_rebuild_graph() {
1500 use super::super::staging::StagingGraph;
1501 use crate::graph::unified::concurrent::CodeGraph;
1502 use crate::graph::unified::mutation_target::GraphMutationTarget;
1503 use crate::graph::unified::node::NodeKind;
1504
1505 let mut sg = StagingGraph::new();
1509 let local_name = StringId::new_local(0);
1510 sg.intern_string(local_name, "rebuild_target".into());
1511 let entry = NodeEntry::new(NodeKind::Function, local_name, FileId::new(0));
1512 let n0 = sg.add_node(entry.clone());
1513 let entry2 = NodeEntry::new(NodeKind::Variable, local_name, FileId::new(0));
1514 let n1 = sg.add_node(entry2);
1515 sg.add_edge(
1516 n0,
1517 n1,
1518 EdgeKind::Calls {
1519 argument_count: 0,
1520 is_async: false,
1521 },
1522 FileId::new(0),
1523 );
1524
1525 let mut rebuild = {
1529 let graph = CodeGraph::new();
1530 graph.clone_for_rebuild()
1531 };
1532
1533 rebuild
1539 .nodes_mut()
1540 .alloc_range(10, &placeholder_entry())
1541 .unwrap();
1542 let string_start = rebuild.strings_mut().alloc_range(1).unwrap();
1543 assert_eq!(string_start, 1);
1544
1545 let file_ids = vec![FileId::new(5)];
1546 let offsets = GlobalOffsets {
1547 node_offset: 10,
1548 string_offset: string_start,
1549 };
1550 let plan = phase2_assign_ranges(&[&sg], &file_ids, &offsets);
1551
1552 rebuild
1553 .nodes_mut()
1554 .alloc_range(plan.total_nodes, &placeholder_entry())
1555 .unwrap();
1556 rebuild
1557 .strings_mut()
1558 .alloc_range(plan.total_strings)
1559 .unwrap();
1560
1561 let result = phase3_parallel_commit(&plan, &[&sg], &mut rebuild);
1563
1564 let committed_ids = &result.per_file_node_ids[0];
1578 assert_eq!(
1579 committed_ids,
1580 &vec![NodeId::new(10, 1), NodeId::new(11, 1)],
1581 "Phase 3 must commit into slots 10..12 on the rebuild-local arena"
1582 );
1583
1584 let resolved_name = rebuild
1585 .nodes_mut()
1586 .get(NodeId::new(10, 1))
1587 .map(|entry| entry.name)
1588 .expect("committed node must exist in rebuild arena");
1589 let resolved_str = rebuild
1593 .strings_mut()
1594 .resolve(resolved_name)
1595 .expect("name must resolve in rebuild-local interner");
1596 assert_eq!(&*resolved_str, "rebuild_target");
1597
1598 assert_eq!(result.total_nodes_written, 2);
1600 assert_eq!(result.total_strings_written, 1);
1601 assert_eq!(result.per_file_edges.len(), 1);
1602 assert_eq!(result.per_file_edges[0].len(), 1);
1603 let edge = &result.per_file_edges[0][0];
1604 assert_eq!(edge.file, FileId::new(5));
1605 assert_eq!(edge.source, NodeId::new(10, 1));
1606 assert_eq!(edge.target, NodeId::new(11, 1));
1607 }
1608
1609 #[test]
1610 fn test_commit_single_file_string_remap() {
1611 use super::super::staging::StagingGraph;
1612 use crate::graph::unified::node::NodeKind;
1613
1614 let mut sg = StagingGraph::new();
1615 let local_0 = StringId::new_local(0);
1616 let local_1 = StringId::new_local(1);
1617 sg.intern_string(local_0, "alpha".into());
1618 sg.intern_string(local_1, "beta".into());
1619
1620 let mut entry = NodeEntry::new(NodeKind::Function, local_0, FileId::new(0));
1621 entry.signature = Some(local_1);
1622 sg.add_node(entry);
1623
1624 let plan = FilePlan {
1625 parsed_index: 0,
1626 file_id: FileId::new(42),
1627 node_range: 10..11,
1628 string_range: 20..22,
1629 };
1630
1631 let mut node_slots = vec![Slot::new_occupied(1, placeholder_entry())];
1632 let mut str_slots: Vec<Option<Arc<str>>> = vec![None, None];
1633 let mut rc_slots: Vec<u32> = vec![0, 0];
1634
1635 let result = commit_single_file(&sg, &plan, &mut node_slots, &mut str_slots, &mut rc_slots);
1636
1637 assert_eq!(str_slots[0].as_deref(), Some("alpha"));
1639 assert_eq!(str_slots[1].as_deref(), Some("beta"));
1640 assert_eq!(rc_slots[0], 1);
1641 assert_eq!(rc_slots[1], 1);
1642 assert_eq!(result.strings_written, 2);
1643
1644 if let crate::graph::unified::storage::SlotState::Occupied(entry) = node_slots[0].state() {
1646 assert_eq!(entry.name, StringId::new(20)); assert_eq!(entry.signature, Some(StringId::new(21))); assert_eq!(entry.file, FileId::new(42));
1649 } else {
1650 panic!("Expected occupied slot");
1651 }
1652 assert_eq!(result.nodes_written, 1);
1653
1654 assert_eq!(result.node_ids, vec![NodeId::new(10, 1)]);
1656
1657 assert!(result.edges.is_empty());
1659 }
1660
1661 #[test]
1662 fn test_remap_edge_kind_message_queue_other() {
1663 let mut remap = HashMap::new();
1664 remap.insert(StringId::new(10), StringId::new(110));
1665 remap.insert(StringId::new(20), StringId::new(220));
1666
1667 let mut kind = EdgeKind::MessageQueue {
1668 protocol: MqProtocol::Other(StringId::new(10)),
1669 topic: Some(StringId::new(20)),
1670 };
1671 remap_edge_kind_string_ids(&mut kind, &remap);
1672 assert!(matches!(
1673 kind,
1674 EdgeKind::MessageQueue {
1675 protocol: MqProtocol::Other(proto),
1676 topic: Some(topic),
1677 } if proto == StringId::new(110) && topic == StringId::new(220)
1678 ));
1679 }
1680
1681 #[test]
1684 fn test_phase4_apply_global_remap_basic() {
1685 use crate::graph::unified::node::NodeKind;
1686 use crate::graph::unified::storage::NodeArena;
1687
1688 let mut arena = NodeArena::new();
1689
1690 let entry1 = NodeEntry::new(NodeKind::Function, StringId::new(1), FileId::new(0));
1692 let mut entry2 = NodeEntry::new(NodeKind::Variable, StringId::new(2), FileId::new(0));
1693 entry2.signature = Some(StringId::new(3));
1694
1695 arena.alloc(entry1).unwrap();
1696 arena.alloc(entry2).unwrap();
1697
1698 let mut all_edges = vec![vec![PendingEdge {
1700 source: NodeId::new(0, 1),
1701 target: NodeId::new(1, 1),
1702 kind: EdgeKind::Imports {
1703 alias: Some(StringId::new(3)),
1704 is_wildcard: false,
1705 },
1706 file: FileId::new(0),
1707 spans: vec![],
1708 }]];
1709
1710 let mut remap = HashMap::new();
1712 remap.insert(StringId::new(2), StringId::new(1));
1713 remap.insert(StringId::new(3), StringId::new(1));
1714
1715 phase4_apply_global_remap(&mut arena, &mut all_edges, &remap);
1716
1717 let (_, entry) = arena.iter().nth(1).unwrap();
1719 assert_eq!(entry.name, StringId::new(1));
1720 assert_eq!(entry.signature, Some(StringId::new(1)));
1721
1722 if let EdgeKind::Imports { alias, .. } = &all_edges[0][0].kind {
1724 assert_eq!(*alias, Some(StringId::new(1)));
1725 } else {
1726 panic!("Expected Imports edge");
1727 }
1728 }
1729
1730 #[test]
1731 fn test_phase4_apply_global_remap_empty() {
1732 use crate::graph::unified::storage::NodeArena;
1733
1734 let mut arena = NodeArena::new();
1735 let mut edges: Vec<Vec<PendingEdge>> = vec![];
1736 let remap = HashMap::new();
1737
1738 phase4_apply_global_remap(&mut arena, &mut edges, &remap);
1740 }
1741
1742 #[test]
1743 fn test_pending_edges_to_delta_basic() {
1744 let edges = vec![
1745 vec![
1746 PendingEdge {
1747 source: NodeId::new(0, 1),
1748 target: NodeId::new(1, 1),
1749 kind: EdgeKind::Calls {
1750 argument_count: 0,
1751 is_async: false,
1752 },
1753 file: FileId::new(0),
1754 spans: vec![],
1755 },
1756 PendingEdge {
1757 source: NodeId::new(1, 1),
1758 target: NodeId::new(2, 1),
1759 kind: EdgeKind::References,
1760 file: FileId::new(0),
1761 spans: vec![],
1762 },
1763 ],
1764 vec![PendingEdge {
1765 source: NodeId::new(3, 1),
1766 target: NodeId::new(4, 1),
1767 kind: EdgeKind::Defines,
1768 file: FileId::new(1),
1769 spans: vec![],
1770 }],
1771 ];
1772
1773 let (deltas, final_seq) = pending_edges_to_delta(&edges, 100);
1774
1775 assert_eq!(deltas.len(), 2);
1776 assert_eq!(deltas[0].len(), 2);
1777 assert_eq!(deltas[1].len(), 1);
1778 assert_eq!(final_seq, 103);
1779
1780 assert_eq!(deltas[0][0].seq, 100);
1782 assert_eq!(deltas[0][1].seq, 101);
1783 assert_eq!(deltas[1][0].seq, 102);
1784
1785 assert!(matches!(deltas[0][0].op, DeltaOp::Add));
1787 assert!(matches!(deltas[1][0].op, DeltaOp::Add));
1788 }
1789
1790 #[test]
1791 fn test_pending_edges_to_delta_empty() {
1792 let edges: Vec<Vec<PendingEdge>> = vec![];
1793 let (deltas, final_seq) = pending_edges_to_delta(&edges, 0);
1794 assert!(deltas.is_empty());
1795 assert_eq!(final_seq, 0);
1796 }
1797
1798 #[test]
1817 #[cfg(feature = "rebuild-internals")]
1818 fn phase4c_prime_unify_cross_file_nodes_runs_against_rebuild_graph() {
1819 use crate::graph::unified::concurrent::CodeGraph;
1820 use crate::graph::unified::mutation_target::GraphMutationTarget;
1821 use crate::graph::unified::node::NodeKind;
1822
1823 let mut rebuild = {
1824 let graph = CodeGraph::new();
1825 graph.clone_for_rebuild()
1826 };
1827
1828 let qname_sid = rebuild.strings_mut().intern("my_mod::my_func").unwrap();
1831
1832 let file_a = FileId::new(7);
1834 let file_b = FileId::new(8);
1835
1836 let mut winner_entry = NodeEntry::new(NodeKind::Function, qname_sid, file_a);
1841 winner_entry.qualified_name = Some(qname_sid);
1842 winner_entry.start_line = 10;
1843 winner_entry.end_line = 30;
1844
1845 let mut loser_entry = NodeEntry::new(NodeKind::Function, qname_sid, file_b);
1846 loser_entry.qualified_name = Some(qname_sid);
1847 loser_entry.start_line = 5;
1849 loser_entry.end_line = 6;
1850
1851 let winner_id = rebuild.nodes_mut().alloc(winner_entry).unwrap();
1852 let loser_id = rebuild.nodes_mut().alloc(loser_entry).unwrap();
1853
1854 let mut all_edges = vec![vec![PendingEdge {
1857 source: winner_id, target: loser_id,
1859 kind: EdgeKind::Calls {
1860 argument_count: 0,
1861 is_async: false,
1862 },
1863 file: file_b,
1864 spans: vec![],
1865 }]];
1866
1867 let stats = phase4c_prime_unify_cross_file_nodes(&mut rebuild, &mut all_edges);
1868
1869 assert_eq!(stats.nodes_merged, 1, "exactly one loser was tombstoned");
1871 assert_eq!(stats.candidate_pairs_examined, 1);
1872 assert_eq!(stats.edges_rewritten, 1);
1873
1874 let winner_entry_after = GraphMutationTarget::nodes(&rebuild)
1876 .get(winner_id)
1877 .expect("winner must remain live");
1878 assert_eq!(
1879 winner_entry_after.qualified_name,
1880 Some(qname_sid),
1881 "winner keeps its qualified_name"
1882 );
1883
1884 let loser_entry_after = GraphMutationTarget::nodes(&rebuild)
1887 .get(loser_id)
1888 .expect("loser slot remains live (inert) per §F.1 bijection");
1889 assert_eq!(
1890 loser_entry_after.qualified_name, None,
1891 "loser qualified_name cleared by merge_node_into"
1892 );
1893
1894 assert_eq!(
1896 all_edges[0][0].target, winner_id,
1897 "PendingEdge.target rewritten from loser → winner"
1898 );
1899 }
1900
1901 #[test]
1911 #[cfg(feature = "rebuild-internals")]
1912 fn phase4c_prime_tie_break_prefers_lex_smaller_path_over_node_id() {
1913 use crate::graph::unified::concurrent::CodeGraph;
1914 use crate::graph::unified::node::NodeKind;
1915 use std::path::Path;
1916
1917 let mut graph = CodeGraph::new();
1918 let qname = graph.strings_mut().intern("shared_qname").unwrap();
1919 let high_path_file = graph
1925 .files_mut()
1926 .register(Path::new("zzz_late.rs"))
1927 .unwrap();
1928 let low_path_file = graph
1929 .files_mut()
1930 .register(Path::new("aaa_early.rs"))
1931 .unwrap();
1932
1933 let mut high_entry = NodeEntry::new(NodeKind::Function, qname, high_path_file);
1939 high_entry.qualified_name = Some(qname);
1940 high_entry.start_line = 10;
1941 high_entry.end_line = 20;
1942 let high_node = graph.nodes_mut().alloc(high_entry).unwrap();
1943
1944 let mut low_entry = NodeEntry::new(NodeKind::Function, qname, low_path_file);
1945 low_entry.qualified_name = Some(qname);
1946 low_entry.start_line = 10;
1949 low_entry.end_line = 20;
1950 let low_node = graph.nodes_mut().alloc(low_entry).unwrap();
1951
1952 graph.rebuild_indices();
1953
1954 let mut all_edges: Vec<Vec<PendingEdge>> = Vec::new();
1955 let stats = phase4c_prime_unify_cross_file_nodes(&mut graph, &mut all_edges);
1956
1957 assert_eq!(
1958 stats.nodes_merged, 1,
1959 "one of the duplicate nodes must be merged into the other"
1960 );
1961
1962 let low_after = graph
1965 .nodes()
1966 .get(low_node)
1967 .expect("winner slot remains live");
1968 assert_eq!(
1969 low_after.qualified_name,
1970 Some(qname),
1971 "path-earlier node keeps qualified_name as the unification winner"
1972 );
1973
1974 let high_after = graph
1977 .nodes()
1978 .get(high_node)
1979 .expect("loser slot remains inert (Gate 0d bijection contract)");
1980 assert_eq!(
1981 high_after.qualified_name, None,
1982 "path-later node loses even when its NodeId::index() is smaller"
1983 );
1984 }
1985
1986 #[test]
1993 #[cfg(feature = "rebuild-internals")]
1994 fn phase4c_prime_tie_break_falls_back_to_smaller_node_id_on_identical_path() {
1995 use crate::graph::unified::concurrent::CodeGraph;
1996 use crate::graph::unified::node::NodeKind;
1997 use std::path::Path;
1998
1999 let mut graph = CodeGraph::new();
2000 let qname = graph.strings_mut().intern("shared_qname").unwrap();
2001 let file = graph.files_mut().register(Path::new("shared.rs")).unwrap();
2002
2003 let mut first_entry = NodeEntry::new(NodeKind::Function, qname, file);
2009 first_entry.qualified_name = Some(qname);
2010 first_entry.start_line = 1;
2011 first_entry.end_line = 5;
2012 let first_node = graph.nodes_mut().alloc(first_entry).unwrap();
2013
2014 let mut second_entry = NodeEntry::new(NodeKind::Function, qname, file);
2015 second_entry.qualified_name = Some(qname);
2016 second_entry.start_line = 1;
2017 second_entry.end_line = 5;
2018 let second_node = graph.nodes_mut().alloc(second_entry).unwrap();
2019
2020 assert!(
2021 first_node.index() < second_node.index(),
2022 "precondition: first_node's arena slot precedes second_node's"
2023 );
2024
2025 graph.rebuild_indices();
2026
2027 let mut all_edges: Vec<Vec<PendingEdge>> = Vec::new();
2028 let stats = phase4c_prime_unify_cross_file_nodes(&mut graph, &mut all_edges);
2029
2030 assert_eq!(stats.nodes_merged, 1);
2031
2032 let winner_after = graph.nodes().get(first_node).expect("winner live");
2034 assert_eq!(
2035 winner_after.qualified_name,
2036 Some(qname),
2037 "smaller-index node wins the same-path / same-span tie-break"
2038 );
2039 let loser_after = graph.nodes().get(second_node).expect("loser inert");
2040 assert_eq!(
2041 loser_after.qualified_name, None,
2042 "larger-index node loses the same-path / same-span tie-break"
2043 );
2044 }
2045
2046 #[test]
2051 #[cfg(feature = "rebuild-internals")]
2052 fn rebuild_indices_runs_against_rebuild_graph() {
2053 use crate::graph::unified::concurrent::CodeGraph;
2054 use crate::graph::unified::mutation_target::GraphMutationTarget;
2055 use crate::graph::unified::node::NodeKind;
2056
2057 let mut code_graph = CodeGraph::new();
2059 let alpha_id_code = code_graph.strings_mut().intern("alpha").unwrap();
2060 let mut code_entry = NodeEntry::new(NodeKind::Function, alpha_id_code, FileId::new(1));
2061 code_entry.qualified_name = Some(alpha_id_code);
2062 let code_node_id = code_graph.nodes_mut().alloc(code_entry).unwrap();
2063 rebuild_indices(&mut code_graph);
2064 let code_buckets_function: Vec<NodeId> =
2065 code_graph.indices().by_kind(NodeKind::Function).to_vec();
2066
2067 let mut rebuild = {
2069 let graph = CodeGraph::new();
2070 graph.clone_for_rebuild()
2071 };
2072 let alpha_id_rebuild = rebuild.strings_mut().intern("alpha").unwrap();
2073 let mut rebuild_entry =
2074 NodeEntry::new(NodeKind::Function, alpha_id_rebuild, FileId::new(1));
2075 rebuild_entry.qualified_name = Some(alpha_id_rebuild);
2076 let rebuild_node_id = rebuild.nodes_mut().alloc(rebuild_entry).unwrap();
2077 rebuild_indices(&mut rebuild);
2078
2079 assert_eq!(code_node_id, rebuild_node_id);
2083
2084 let rebuild_buckets_function: Vec<NodeId> = GraphMutationTarget::indices(&rebuild)
2088 .by_kind(NodeKind::Function)
2089 .to_vec();
2090
2091 assert_eq!(
2092 code_buckets_function, rebuild_buckets_function,
2093 "rebuild_indices must produce equivalent Function buckets on both paths"
2094 );
2095 let by_name: Vec<NodeId> = GraphMutationTarget::indices(&rebuild)
2097 .by_name(alpha_id_rebuild)
2098 .to_vec();
2099 assert_eq!(by_name, vec![rebuild_node_id]);
2100 }
2101
2102 #[test]
2107 #[cfg(feature = "rebuild-internals")]
2108 fn phase4d_bulk_insert_edges_runs_against_rebuild_graph() {
2109 use crate::graph::unified::concurrent::CodeGraph;
2110 use crate::graph::unified::mutation_target::GraphMutationTarget;
2111 use crate::graph::unified::node::NodeKind;
2112
2113 let mut rebuild = {
2114 let graph = CodeGraph::new();
2115 graph.clone_for_rebuild()
2116 };
2117
2118 let name_sid = rebuild.strings_mut().intern("edge_target").unwrap();
2119 let file = FileId::new(3);
2120
2121 let n_source = rebuild
2122 .nodes_mut()
2123 .alloc(NodeEntry::new(NodeKind::Function, name_sid, file))
2124 .unwrap();
2125 let n_target = rebuild
2126 .nodes_mut()
2127 .alloc(NodeEntry::new(NodeKind::Variable, name_sid, file))
2128 .unwrap();
2129
2130 let pre_counter = GraphMutationTarget::edges(&rebuild).forward().seq_counter();
2132
2133 let per_file_edges = vec![vec![
2134 PendingEdge {
2135 source: n_source,
2136 target: n_target,
2137 kind: EdgeKind::Calls {
2138 argument_count: 0,
2139 is_async: false,
2140 },
2141 file,
2142 spans: vec![],
2143 },
2144 PendingEdge {
2145 source: n_source,
2146 target: n_target,
2147 kind: EdgeKind::Calls {
2148 argument_count: 1,
2149 is_async: false,
2150 },
2151 file,
2152 spans: vec![],
2153 },
2154 ]];
2155
2156 let final_seq = phase4d_bulk_insert_edges(&mut rebuild, &per_file_edges);
2157
2158 assert_eq!(
2160 final_seq,
2161 pre_counter + 2,
2162 "phase4d_bulk_insert_edges must advance seq by edge count"
2163 );
2164
2165 let forward = GraphMutationTarget::edges(&rebuild).forward();
2167 let after_counter = forward.seq_counter();
2168 assert_eq!(after_counter, pre_counter + 2);
2169 assert!(
2171 forward.delta().iter().filter(|e| e.is_add()).count() >= 2,
2172 "expected at least two Add edges in the rebuild-local forward delta"
2173 );
2174 drop(forward);
2175
2176 let empty_final = phase4d_bulk_insert_edges(&mut rebuild, &[]);
2178 assert_eq!(empty_final, pre_counter + 2, "empty input is a no-op");
2179 }
2180}