1use regex::Regex;
2use serde_json::{json, Map, Value};
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::sync::OnceLock;
6
7const MAX_GRAPH_FILE_BYTES: u64 = 512 * 1024 * 1024;
8
9fn suppression_decl_re() -> &'static Regex {
10 static RE: OnceLock<Regex> = OnceLock::new();
11 RE.get_or_init(|| Regex::new(r"^\s*(?P<name>seen_[A-Za-z0-9_]+)\s*[:=]").unwrap())
12}
13
14fn type_tuple_re() -> &'static Regex {
15 static RE: OnceLock<Regex> = OnceLock::new();
16 RE.get_or_init(|| Regex::new(r"set\[tuple\[(?P<inside>[^\]]+)\]\]").unwrap())
17}
18
19fn safe_text(value: Option<&Value>) -> String {
20 match value {
21 None | Some(Value::Null) => String::new(),
22 Some(Value::String(s)) => s.clone(),
23 Some(Value::Number(n)) => n.to_string(),
24 Some(Value::Bool(b)) => b.to_string(),
25 Some(other) => serde_json::to_string(other).unwrap_or_default(),
26 }
27}
28
29fn edge_list(extraction: &Value) -> Vec<Value> {
30 let obj = match extraction.as_object() {
31 Some(o) => o,
32 None => return vec![],
33 };
34 let edges = obj
35 .get("edges")
36 .filter(|v| v.is_array())
37 .or_else(|| obj.get("links").filter(|v| v.is_array()));
38 match edges {
39 Some(Value::Array(arr)) => arr.clone(),
40 _ => vec![],
41 }
42}
43
44fn node_ids(extraction: &Value) -> HashSet<String> {
45 let arr = match extraction.get("nodes").and_then(|v| v.as_array()) {
46 Some(a) => a,
47 None => return HashSet::new(),
48 };
49 arr.iter()
50 .filter_map(|node| {
51 let obj = node.as_object()?;
52 let id = obj.get("id")?;
53 if id.is_null() {
54 return None;
55 }
56 Some(safe_text(Some(id)))
57 })
58 .filter(|s| !s.is_empty())
59 .collect()
60}
61
62#[derive(Debug, Clone)]
63struct CanonEdge {
64 source: String,
65 target: String,
66 relation: String,
67 #[allow(dead_code)]
68 confidence: String,
69 source_file: String,
70 source_location: String,
71 context: String,
72 invalid: String,
73}
74
75impl CanonEdge {
76 fn field(&self, name: &str) -> String {
77 match name {
78 "relation" => self.relation.clone(),
79 "source_file" => self.source_file.clone(),
80 "source_location" => self.source_location.clone(),
81 "context" => self.context.clone(),
82 _ => String::new(),
83 }
84 }
85}
86
87fn canonical_edge(edge: &Value) -> CanonEdge {
88 match edge.as_object() {
89 None => CanonEdge {
90 source: String::new(),
91 target: String::new(),
92 relation: String::new(),
93 confidence: String::new(),
94 source_file: String::new(),
95 source_location: String::new(),
96 context: String::new(),
97 invalid: "non_object_edge".to_string(),
98 },
99 Some(obj) => {
100 let source = safe_text(
101 obj.get("source")
102 .or_else(|| obj.get("from"))
103 .filter(|v| !v.is_null()),
104 );
105 let target = safe_text(
106 obj.get("target")
107 .or_else(|| obj.get("to"))
108 .filter(|v| !v.is_null()),
109 );
110 CanonEdge {
111 source,
112 target,
113 relation: safe_text(obj.get("relation")),
114 confidence: safe_text(obj.get("confidence")),
115 source_file: safe_text(obj.get("source_file")),
116 source_location: safe_text(obj.get("source_location")),
117 context: safe_text(obj.get("context")),
118 invalid: String::new(),
119 }
120 }
121 }
122}
123
124fn exact_signature(edge: &Value) -> String {
125 let obj = match edge.as_object() {
126 Some(o) => o,
127 None => return "<non-object>".to_string(),
128 };
129 let mut normalized: Map<String, Value> = Map::new();
130 for (k, v) in obj {
131 if k == "from" && !obj.contains_key("source") {
132 normalized.insert("source".to_string(), v.clone());
133 } else if k == "to" && !obj.contains_key("target") {
134 normalized.insert("target".to_string(), v.clone());
135 } else if k != "from" && k != "to" {
136 normalized.insert(k.clone(), v.clone());
137 }
138 }
139 let mut sorted: Vec<(String, Value)> = normalized.into_iter().collect();
140 sorted.sort_by_key(|k| k.0.clone());
141 let sorted_obj: Map<String, Value> = sorted.into_iter().collect();
142 serde_json::to_string(&Value::Object(sorted_obj)).unwrap_or_default()
143}
144
145fn count_extra(counts: &HashMap<String, usize>) -> usize {
146 counts.values().filter(|&&c| c > 1).map(|&c| c - 1).sum()
147}
148
149fn variant_group_count(
150 grouped: &HashMap<(String, String), Vec<CanonEdge>>,
151 field: &str,
152 relation_sensitive: bool,
153) -> usize {
154 let mut groups = 0;
155 for edges in grouped.values() {
156 if relation_sensitive {
157 let mut by_relation: HashMap<&str, HashSet<String>> = HashMap::new();
158 for edge in edges {
159 by_relation
160 .entry(edge.relation.as_str())
161 .or_default()
162 .insert(edge.field(field));
163 }
164 groups += by_relation.values().filter(|vals| vals.len() > 1).count();
165 } else {
166 let vals: HashSet<String> = edges.iter().map(|e| e.field(field)).collect();
167 if vals.len() > 1 {
168 groups += 1;
169 }
170 }
171 }
172 groups
173}
174
175fn tuple_arity_from_annotation(line: &str) -> usize {
176 let re = type_tuple_re();
177 match re.captures(line) {
178 None => 0,
179 Some(caps) => {
180 let inside = caps["inside"].trim();
181 if inside.is_empty() {
182 0
183 } else {
184 inside.chars().filter(|&c| c == ',').count() + 1
185 }
186 }
187 }
188}
189
190#[derive(Debug, Clone)]
191pub struct SuppressionSite {
192 pub line: usize,
193 pub name: String,
194 pub tuple_arity: usize,
195 pub sample: String,
196}
197
198#[derive(Debug, Clone)]
199pub struct SuppressionResult {
200 pub path: String,
201 pub total_sites: usize,
202 pub sites: Vec<SuppressionSite>,
203 pub error: String,
204}
205
206pub fn scan_producer_suppression_sites(path: &Path) -> SuppressionResult {
207 if !path.exists() {
208 return SuppressionResult {
209 path: path.display().to_string(),
210 total_sites: 0,
211 sites: vec![],
212 error: "file not found".to_string(),
213 };
214 }
215 let text = match std::fs::read_to_string(path) {
216 Err(e) => {
217 return SuppressionResult {
218 path: path.display().to_string(),
219 total_sites: 0,
220 sites: vec![],
221 error: e.to_string(),
222 }
223 }
224 Ok(t) => t,
225 };
226 let re = suppression_decl_re();
227 let mut sites: Vec<SuppressionSite> = Vec::new();
228 for (i, line) in text.lines().enumerate() {
229 if let Some(caps) = re.captures(line) {
230 let name = caps["name"].to_string();
231 let arity = tuple_arity_from_annotation(line);
232 let sample = line.trim().chars().take(120).collect();
233 sites.push(SuppressionSite {
234 line: i + 1,
235 name,
236 tuple_arity: arity,
237 sample,
238 });
239 }
240 }
241 SuppressionResult {
242 path: path.display().to_string(),
243 total_sites: sites.len(),
244 sites,
245 error: String::new(),
246 }
247}
248
249#[derive(Debug, Clone)]
250pub struct EdgeGroupExample {
251 pub source: String,
252 pub target: String,
253 pub edge_count: usize,
254 pub relations: Vec<String>,
255 pub source_files: Vec<String>,
256 pub source_locations: Vec<String>,
257 pub contexts: Vec<String>,
258}
259
260#[derive(Debug, Clone)]
261pub struct DiagnosticSummary {
262 pub node_count: usize,
263 pub raw_edge_count: usize,
264 pub non_object_edges: usize,
265 pub missing_endpoint_edges: usize,
266 pub dangling_endpoint_edges: usize,
267 pub self_loop_edges: usize,
268 pub valid_candidate_edges: usize,
269 pub exact_duplicate_edges: usize,
270 pub directed_unique_endpoint_pairs: usize,
271 pub directed_same_endpoint_collapsed_edges: usize,
272 pub undirected_unique_endpoint_pairs: usize,
273 pub undirected_same_endpoint_collapsed_edges: usize,
274 pub same_endpoint_group_count: usize,
275 pub relation_variant_groups: usize,
276 pub source_file_variant_groups: usize,
277 pub source_location_variant_groups: usize,
278 pub context_variant_groups: usize,
279 pub post_build_graph_type: String,
280 pub post_build_node_count: Option<usize>,
281 pub post_build_edge_count: Option<usize>,
282 pub post_build_error: String,
283 pub producer_suppression: SuppressionResult,
284 pub examples: Vec<EdgeGroupExample>,
285 pub input_path: Option<String>,
286 pub effective_directed: Option<bool>,
287}
288
289fn simulate_post_build(
290 valid_edges: &[(String, String)],
291 node_ids: &HashSet<String>,
292 directed: bool,
293) -> (String, Option<usize>, Option<usize>, String) {
294 let graph_type = if directed { "DiGraph" } else { "Graph" };
295 if directed {
296 let unique: HashSet<(&str, &str)> = valid_edges
297 .iter()
298 .map(|(s, t)| (s.as_str(), t.as_str()))
299 .collect();
300 let unique_nodes: HashSet<&str> = valid_edges
301 .iter()
302 .flat_map(|(s, t)| [s.as_str(), t.as_str()])
303 .filter(|id| node_ids.contains(*id))
304 .collect();
305 (
306 graph_type.to_string(),
307 Some(unique_nodes.len()),
308 Some(unique.len()),
309 String::new(),
310 )
311 } else {
312 let unique: HashSet<(String, String)> = valid_edges
313 .iter()
314 .map(|(s, t)| {
315 if s <= t {
316 (s.clone(), t.clone())
317 } else {
318 (t.clone(), s.clone())
319 }
320 })
321 .collect();
322 let unique_nodes: HashSet<&str> = valid_edges
323 .iter()
324 .flat_map(|(s, t)| [s.as_str(), t.as_str()])
325 .filter(|id| node_ids.contains(*id))
326 .collect();
327 (
328 graph_type.to_string(),
329 Some(unique_nodes.len()),
330 Some(unique.len()),
331 String::new(),
332 )
333 }
334}
335
336pub fn diagnose_extraction(
337 extraction: &Value,
338 directed: bool,
339 max_examples: usize,
340 extract_path: Option<&Path>,
341) -> DiagnosticSummary {
342 let ids = node_ids(extraction);
343 let raw_edges = edge_list(extraction);
344 let canonical: Vec<CanonEdge> = raw_edges.iter().map(canonical_edge).collect();
345
346 let mut exact_counts: HashMap<String, usize> = HashMap::new();
347 let mut directed_pair_counts: HashMap<(String, String), usize> = HashMap::new();
348 let mut undirected_pair_counts: HashMap<(String, String), usize> = HashMap::new();
349 let mut grouped: HashMap<(String, String), Vec<CanonEdge>> = HashMap::new();
350
351 let mut non_object_edges = 0usize;
352 let mut missing_endpoint_edges = 0usize;
353 let mut dangling_endpoint_edges = 0usize;
354 let mut self_loop_edges = 0usize;
355 let mut valid_candidate_edges = 0usize;
356 let mut valid_pairs: Vec<(String, String)> = Vec::new();
357
358 let has_non_dict_node = extraction
359 .get("nodes")
360 .and_then(|v| v.as_array())
361 .map(|arr| arr.iter().any(|n| !n.is_object()))
362 .unwrap_or(false);
363
364 for (i, raw_edge) in raw_edges.iter().enumerate() {
365 let canon = &canonical[i];
366 if !canon.invalid.is_empty() {
367 non_object_edges += 1;
368 continue;
369 }
370 if canon.source.is_empty() || canon.target.is_empty() {
371 missing_endpoint_edges += 1;
372 continue;
373 }
374 if !ids.contains(&canon.source) || !ids.contains(&canon.target) {
375 dangling_endpoint_edges += 1;
376 continue;
377 }
378 if canon.source == canon.target {
379 self_loop_edges += 1;
380 }
381 valid_candidate_edges += 1;
382 valid_pairs.push((canon.source.clone(), canon.target.clone()));
383
384 let sig = exact_signature(raw_edge);
385 *exact_counts.entry(sig).or_insert(0) += 1;
386
387 let dir_pair = (canon.source.clone(), canon.target.clone());
388 *directed_pair_counts.entry(dir_pair.clone()).or_insert(0) += 1;
389 grouped.entry(dir_pair).or_default().push(canon.clone());
390
391 let (a, b) = if canon.source <= canon.target {
392 (canon.source.clone(), canon.target.clone())
393 } else {
394 (canon.target.clone(), canon.source.clone())
395 };
396 *undirected_pair_counts.entry((a, b)).or_insert(0) += 1;
397 }
398
399 let exact_duplicate_edges = count_extra(&exact_counts);
400 let directed_unique = directed_pair_counts.len();
401 let directed_collapsed = count_extra(
402 &directed_pair_counts
403 .iter()
404 .map(|(k, v)| (format!("{}->{}", k.0, k.1), *v))
405 .collect(),
406 );
407 let undirected_unique = undirected_pair_counts.len();
408 let undirected_collapsed = count_extra(
409 &undirected_pair_counts
410 .iter()
411 .map(|(k, v)| (format!("{}<>{}", k.0, k.1), *v))
412 .collect(),
413 );
414 let same_endpoint_group_count = directed_pair_counts.values().filter(|&&c| c > 1).count();
415
416 let relation_variant_groups = variant_group_count(&grouped, "relation", false);
417 let source_file_variant_groups = variant_group_count(&grouped, "source_file", true);
418 let source_location_variant_groups = variant_group_count(&grouped, "source_location", true);
419 let context_variant_groups = variant_group_count(&grouped, "context", true);
420
421 let post_build_error = if has_non_dict_node {
422 "TypeError: non-object node in nodes list".to_string()
423 } else {
424 String::new()
425 };
426
427 let (post_build_graph_type, post_build_node_count, post_build_edge_count, _) =
428 if post_build_error.is_empty() {
429 simulate_post_build(&valid_pairs, &ids, directed)
430 } else {
431 (String::new(), None, None, String::new())
432 };
433
434 let suppression_path = extract_path
435 .map(|p| p.to_path_buf())
436 .unwrap_or_else(|| Path::new("extract.py").to_path_buf());
437
438 let mut examples: Vec<EdgeGroupExample> = Vec::new();
439 if max_examples > 0 {
440 let mut pairs_by_count: Vec<(&(String, String), usize)> =
441 directed_pair_counts.iter().map(|(k, v)| (k, *v)).collect();
442 pairs_by_count.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
443
444 for (pair, count) in pairs_by_count {
445 if count < 2 {
446 continue;
447 }
448 if examples.len() >= max_examples {
449 break;
450 }
451 if let Some(edges) = grouped.get(pair) {
452 let mut relations: Vec<String> = edges
453 .iter()
454 .map(|e| e.relation.clone())
455 .collect::<HashSet<_>>()
456 .into_iter()
457 .collect();
458 relations.sort();
459 let mut source_files: Vec<String> = edges
460 .iter()
461 .map(|e| e.source_file.clone())
462 .collect::<HashSet<_>>()
463 .into_iter()
464 .collect();
465 source_files.sort();
466 let mut source_locations: Vec<String> = edges
467 .iter()
468 .map(|e| e.source_location.clone())
469 .collect::<HashSet<_>>()
470 .into_iter()
471 .collect();
472 source_locations.sort();
473 let mut contexts: Vec<String> = edges
474 .iter()
475 .map(|e| e.context.clone())
476 .collect::<HashSet<_>>()
477 .into_iter()
478 .collect();
479 contexts.sort();
480 examples.push(EdgeGroupExample {
481 source: pair.0.clone(),
482 target: pair.1.clone(),
483 edge_count: count,
484 relations,
485 source_files,
486 source_locations,
487 contexts,
488 });
489 }
490 }
491 }
492
493 DiagnosticSummary {
494 node_count: ids.len(),
495 raw_edge_count: raw_edges.len(),
496 non_object_edges,
497 missing_endpoint_edges,
498 dangling_endpoint_edges,
499 self_loop_edges,
500 valid_candidate_edges,
501 exact_duplicate_edges,
502 directed_unique_endpoint_pairs: directed_unique,
503 directed_same_endpoint_collapsed_edges: directed_collapsed,
504 undirected_unique_endpoint_pairs: undirected_unique,
505 undirected_same_endpoint_collapsed_edges: undirected_collapsed,
506 same_endpoint_group_count,
507 relation_variant_groups,
508 source_file_variant_groups,
509 source_location_variant_groups,
510 context_variant_groups,
511 post_build_graph_type,
512 post_build_node_count,
513 post_build_edge_count,
514 post_build_error,
515 producer_suppression: scan_producer_suppression_sites(&suppression_path),
516 examples,
517 input_path: None,
518 effective_directed: None,
519 }
520}
521
522pub fn diagnose_file(
523 path: &Path,
524 directed: Option<bool>,
525 max_examples: usize,
526 extract_path: Option<&Path>,
527) -> crate::error::Result<DiagnosticSummary> {
528 let size = std::fs::metadata(path)?.len();
529 if size > MAX_GRAPH_FILE_BYTES {
530 return Err(crate::error::CodeSynapseError::Validation(format!(
531 "graph file exceeds {} byte limit",
532 MAX_GRAPH_FILE_BYTES
533 )));
534 }
535 let text = std::fs::read_to_string(path)?;
536 let data: Value = serde_json::from_str(&text)
537 .map_err(|e| crate::error::CodeSynapseError::Validation(format!("invalid JSON: {}", e)))?;
538 if !data.is_object() {
539 return Err(crate::error::CodeSynapseError::Validation(
540 "diagnostic input must be a JSON object".to_string(),
541 ));
542 }
543
544 let effective_directed = match directed {
545 Some(d) => d,
546 None => data
547 .get("directed")
548 .and_then(|v| v.as_bool())
549 .unwrap_or(true),
550 };
551
552 let mut summary = diagnose_extraction(&data, effective_directed, max_examples, extract_path);
553 summary.input_path = Some(path.display().to_string());
554 summary.effective_directed = Some(effective_directed);
555 Ok(summary)
556}
557
558pub fn format_diagnostic_report(summary: &DiagnosticSummary) -> String {
559 let suppression = &summary.producer_suppression;
560 let mut lines = vec![
561 "[codesynapse] MultiDiGraph edge-collapse diagnostic".to_string(),
562 format!(
563 "input: {}",
564 summary.input_path.as_deref().unwrap_or("<in-memory>")
565 ),
566 "input_stage: provided JSON (normal graph.json is post-build)".to_string(),
567 format!(
568 "effective_directed: {}",
569 summary
570 .effective_directed
571 .map(|d| d.to_string())
572 .unwrap_or_else(|| "<direct-call>".to_string())
573 ),
574 format!("nodes: {}", summary.node_count),
575 format!("raw_edges: {}", summary.raw_edge_count),
576 format!("valid_candidate_edges: {}", summary.valid_candidate_edges),
577 format!("missing_endpoint_edges: {}", summary.missing_endpoint_edges),
578 format!(
579 "dangling_endpoint_edges: {}",
580 summary.dangling_endpoint_edges
581 ),
582 format!("self_loop_edges: {}", summary.self_loop_edges),
583 format!("exact_duplicate_edges: {}", summary.exact_duplicate_edges),
584 format!(
585 "directed_unique_endpoint_pairs: {}",
586 summary.directed_unique_endpoint_pairs
587 ),
588 format!(
589 "directed_same_endpoint_collapsed_edges: {}",
590 summary.directed_same_endpoint_collapsed_edges
591 ),
592 format!(
593 "undirected_unique_endpoint_pairs: {}",
594 summary.undirected_unique_endpoint_pairs
595 ),
596 format!(
597 "undirected_same_endpoint_collapsed_edges: {}",
598 summary.undirected_same_endpoint_collapsed_edges
599 ),
600 format!(
601 "same_endpoint_group_count: {}",
602 summary.same_endpoint_group_count
603 ),
604 format!(
605 "relation_variant_groups: {}",
606 summary.relation_variant_groups
607 ),
608 format!(
609 "source_file_variant_groups: {}",
610 summary.source_file_variant_groups
611 ),
612 format!(
613 "source_location_variant_groups: {}",
614 summary.source_location_variant_groups
615 ),
616 format!("context_variant_groups: {}", summary.context_variant_groups),
617 format!("post_build_graph_type: {}", summary.post_build_graph_type),
618 format!(
619 "post_build_edges: {}",
620 summary
621 .post_build_edge_count
622 .map(|n| n.to_string())
623 .unwrap_or_else(|| "None".to_string())
624 ),
625 format!("producer_suppression_sites: {}", suppression.total_sites),
626 ];
627 if !summary.post_build_error.is_empty() {
628 lines.push(format!("post_build_error: {}", summary.post_build_error));
629 }
630 if !suppression.error.is_empty() {
631 lines.push(format!("producer_suppression_error: {}", suppression.error));
632 }
633 if !suppression.sites.is_empty() {
634 lines.push("producer_suppression_examples:".to_string());
635 for site in suppression.sites.iter().take(8) {
636 lines.push(format!(
637 " - L{} {} arity={}",
638 site.line,
639 site.name,
640 if site.tuple_arity == 0 {
641 "unknown".to_string()
642 } else {
643 site.tuple_arity.to_string()
644 }
645 ));
646 }
647 }
648 if !summary.examples.is_empty() {
649 lines.push("examples:".to_string());
650 for ex in &summary.examples {
651 lines.push(format!(
652 " - {} -> {} edges={} relations={:?} locations={:?} contexts={:?}",
653 ex.source, ex.target, ex.edge_count, ex.relations, ex.source_locations, ex.contexts
654 ));
655 }
656 }
657 lines.push(
658 "note: normal graph.json is post-build; raw producer loss must be measured earlier."
659 .to_string(),
660 );
661 lines.join("\n")
662}
663
664pub fn format_diagnostic_json(summary: &DiagnosticSummary) -> Value {
665 let suppression = &summary.producer_suppression;
666 let summary_obj = json!({
667 "node_count": summary.node_count,
668 "raw_edge_count": summary.raw_edge_count,
669 "non_object_edges": summary.non_object_edges,
670 "missing_endpoint_edges": summary.missing_endpoint_edges,
671 "dangling_endpoint_edges": summary.dangling_endpoint_edges,
672 "self_loop_edges": summary.self_loop_edges,
673 "valid_candidate_edges": summary.valid_candidate_edges,
674 "exact_duplicate_edges": summary.exact_duplicate_edges,
675 "directed_unique_endpoint_pairs": summary.directed_unique_endpoint_pairs,
676 "directed_same_endpoint_collapsed_edges": summary.directed_same_endpoint_collapsed_edges,
677 "undirected_unique_endpoint_pairs": summary.undirected_unique_endpoint_pairs,
678 "undirected_same_endpoint_collapsed_edges": summary.undirected_same_endpoint_collapsed_edges,
679 "same_endpoint_group_count": summary.same_endpoint_group_count,
680 "relation_variant_groups": summary.relation_variant_groups,
681 "source_file_variant_groups": summary.source_file_variant_groups,
682 "source_location_variant_groups": summary.source_location_variant_groups,
683 "context_variant_groups": summary.context_variant_groups,
684 "post_build_graph_type": summary.post_build_graph_type,
685 "post_build_node_count": summary.post_build_node_count,
686 "post_build_edge_count": summary.post_build_edge_count,
687 "post_build_error": summary.post_build_error,
688 "input_path": summary.input_path,
689 "effective_directed": summary.effective_directed,
690 });
691
692 let suppression_obj = json!({
693 "path": suppression.path,
694 "total_sites": suppression.total_sites,
695 "sites": suppression.sites.iter().map(|s| json!({
696 "line": s.line,
697 "name": s.name,
698 "tuple_arity": s.tuple_arity,
699 "sample": s.sample,
700 })).collect::<Vec<_>>(),
701 "error": suppression.error,
702 });
703
704 let examples_arr: Vec<Value> = summary
705 .examples
706 .iter()
707 .map(|ex| {
708 json!({
709 "source": ex.source,
710 "target": ex.target,
711 "edge_count": ex.edge_count,
712 "relations": ex.relations,
713 "source_files": ex.source_files,
714 "source_locations": ex.source_locations,
715 "contexts": ex.contexts,
716 })
717 })
718 .collect();
719
720 json!({
721 "schema_version": 1,
722 "summary": summary_obj,
723 "examples": examples_arr,
724 "producer_suppression": suppression_obj,
725 "notes": [
726 "Diagnostics are read-only.",
727 "A normal graph.json is already post-build and cannot recover raw producer edges.",
728 "Producer suppression sites are heuristic source-code evidence.",
729 ],
730 })
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use serde_json::json;
737
738 fn diagnostic_fixture() -> Value {
739 json!({
740 "nodes": [
741 {"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
742 {"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
743 {"id": "c", "label": "C", "file_type": "code", "source_file": "c.py"},
744 ],
745 "edges": [
746 {"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
747 "source_file": "a.py", "source_location": "L1", "context": "call"},
748 {"source": "a", "target": "b", "relation": "imports", "confidence": "EXTRACTED",
749 "source_file": "a.py", "source_location": "L2", "context": "import"},
750 {"source": "a", "target": "b", "relation": "calls", "confidence": "INFERRED",
751 "source_file": "a.py", "source_location": "L3", "context": "call"},
752 {"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
753 "source_file": "a.py", "source_location": "L1", "context": "call"},
754 {"source": "a", "target": "missing", "relation": "calls", "confidence": "EXTRACTED",
755 "source_file": "a.py"},
756 {"source": "a", "relation": "calls", "confidence": "EXTRACTED", "source_file": "a.py"},
757 {"source": "c", "target": "c", "relation": "references", "confidence": "EXTRACTED",
758 "source_file": "c.py"},
759 ],
760 })
761 }
762
763 #[test]
764 fn test_diagnose_extraction_categorizes_same_endpoint_collapse() {
765 let summary = diagnose_extraction(&diagnostic_fixture(), true, 5, None);
766 assert_eq!(summary.node_count, 3);
767 assert_eq!(summary.raw_edge_count, 7);
768 assert_eq!(summary.valid_candidate_edges, 5);
769 assert_eq!(summary.missing_endpoint_edges, 1);
770 assert_eq!(summary.dangling_endpoint_edges, 1);
771 assert_eq!(summary.self_loop_edges, 1);
772 assert_eq!(summary.exact_duplicate_edges, 1);
773 assert_eq!(summary.directed_unique_endpoint_pairs, 2);
774 assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
775 assert_eq!(summary.same_endpoint_group_count, 1);
776 assert_eq!(summary.relation_variant_groups, 1);
777 assert_eq!(summary.source_location_variant_groups, 1);
778 assert_eq!(summary.post_build_graph_type, "DiGraph");
779 assert_eq!(summary.post_build_edge_count, Some(2));
780 }
781
782 #[test]
783 fn test_diagnose_extraction_accepts_node_link_links_key() {
784 let mut extraction = diagnostic_fixture();
785 let edges = extraction["edges"].take();
786 extraction["links"] = edges;
787 let summary = diagnose_extraction(&extraction, true, 5, None);
788 assert_eq!(summary.raw_edge_count, 7);
789 assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
790 }
791
792 #[test]
793 fn test_diagnose_extraction_does_not_mutate_input() {
794 let extraction = diagnostic_fixture();
795 let original = extraction.clone();
796 diagnose_extraction(&extraction, true, 5, None);
797 assert_eq!(extraction, original);
798 }
799
800 #[test]
801 fn test_diagnose_extraction_handles_malformed_shapes_without_crashing() {
802 let extraction = json!({
803 "nodes": [
804 {"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
805 ["not", "a", "node"],
806 {"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
807 ],
808 "edges": [
809 null,
810 ["not", "an", "edge"],
811 {"from": "a", "to": "b", "relation": "legacy_from_to"},
812 {"source": "a", "target": {"unhashable": "target"}, "relation": "bad-target"},
813 {"source": "a", "target": "missing", "relation": "dangling"},
814 {"source": "", "target": "b", "relation": "missing-source"},
815 ],
816 });
817 let summary = diagnose_extraction(&extraction, true, 5, None);
818 assert_eq!(summary.node_count, 2);
819 assert_eq!(summary.raw_edge_count, 6);
820 assert_eq!(summary.non_object_edges, 2);
821 assert_eq!(summary.missing_endpoint_edges, 1);
822 assert_eq!(summary.dangling_endpoint_edges, 2);
823 assert_eq!(summary.valid_candidate_edges, 1);
824 assert!(
825 summary.post_build_error.starts_with("TypeError:"),
826 "expected TypeError prefix: {}",
827 summary.post_build_error
828 );
829 }
830
831 #[test]
832 fn test_diagnose_extraction_handles_non_list_nodes_and_edges() {
833 let extraction = json!({
834 "nodes": {"id": "a"},
835 "edges": {"source": "a", "target": "b"},
836 });
837 let summary = diagnose_extraction(&extraction, true, 5, None);
838 assert_eq!(summary.node_count, 0);
839 assert_eq!(summary.raw_edge_count, 0);
840 assert_eq!(summary.valid_candidate_edges, 0);
841 }
842
843 #[test]
844 fn test_diagnose_extraction_bounds_examples() {
845 let summary = diagnose_extraction(&diagnostic_fixture(), true, 0, None);
846 assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
847 assert!(summary.examples.is_empty());
848 }
849
850 #[test]
851 fn test_diagnose_extraction_stops_examples_at_requested_limit() {
852 let mut extraction = diagnostic_fixture();
853 extraction["nodes"]
854 .as_array_mut()
855 .unwrap()
856 .push(json!({"id": "d", "label": "D", "file_type": "code", "source_file": "d.py"}));
857 extraction["edges"].as_array_mut().unwrap().extend(vec![
858 json!({"source": "b", "target": "d", "relation": "imports", "source_file": "b.py"}),
859 json!({"source": "b", "target": "d", "relation": "calls", "source_file": "b.py"}),
860 ]);
861 let summary = diagnose_extraction(&extraction, true, 1, None);
862 assert_eq!(summary.same_endpoint_group_count, 2);
863 assert_eq!(summary.examples.len(), 1);
864 }
865
866 #[test]
867 fn test_diagnose_extraction_defaults_raw_inputs_to_directed() {
868 let dir = tempfile::tempdir().unwrap();
869 let path = dir.path().join("raw.json");
870 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
871 let summary = diagnose_file(&path, None, 5, None).unwrap();
872 assert_eq!(summary.effective_directed, Some(true));
873 assert_eq!(summary.post_build_graph_type, "DiGraph");
874 }
875
876 #[test]
877 fn test_diagnose_file_reads_json_and_formats_report() {
878 let dir = tempfile::tempdir().unwrap();
879 let path = dir.path().join("graph.json");
880 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
881 let summary = diagnose_file(&path, Some(true), 2, None).unwrap();
882 let report = format_diagnostic_report(&summary);
883 assert_eq!(
884 summary.input_path.as_deref().unwrap(),
885 path.to_str().unwrap()
886 );
887 assert!(
888 report.contains("[codesynapse] MultiDiGraph edge-collapse diagnostic"),
889 "{report}"
890 );
891 assert!(
892 report.contains("directed_same_endpoint_collapsed_edges: 3"),
893 "{report}"
894 );
895 assert!(report.contains("relation_variant_groups: 1"), "{report}");
896 assert!(report.contains("producer_suppression_sites:"), "{report}");
897 assert!(report.contains("examples:"), "{report}");
898 assert!(report.contains("a -> b"), "{report}");
899 }
900
901 #[test]
902 fn test_format_diagnostic_report_includes_build_and_suppression_errors() {
903 let dir = tempfile::tempdir().unwrap();
904 let extraction = json!({
905 "nodes": [
906 {"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
907 ["not", "a", "node"],
908 ],
909 "edges": [],
910 });
911 let summary =
912 diagnose_extraction(&extraction, true, 5, Some(&dir.path().join("missing.py")));
913 let report = format_diagnostic_report(&summary);
914 assert!(report.contains("post_build_error: TypeError:"), "{report}");
915 assert!(
916 report.contains("producer_suppression_error: file not found"),
917 "{report}"
918 );
919 }
920
921 #[test]
922 fn test_diagnostic_json_report_is_serializable() {
923 let dir = tempfile::tempdir().unwrap();
924 let path = dir.path().join("graph.json");
925 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
926 let summary = diagnose_file(&path, Some(true), 5, None).unwrap();
927 let payload = format_diagnostic_json(&summary);
928 assert_eq!(payload["schema_version"], 1);
929 assert_eq!(payload["summary"]["raw_edge_count"], 7);
930 assert!(payload.get("producer_suppression").is_some());
931 serde_json::to_string(&payload).unwrap();
932 }
933
934 #[test]
935 fn test_scan_producer_suppression_sites_finds_seen_sets() {
936 let dir = tempfile::tempdir().unwrap();
937 let path = dir.path().join("extract.py");
938 std::fs::write(
939 &path,
940 "seen_call_pairs: set[tuple[str, str]] = set()\n\
941 seen_static_ref_pairs: set[tuple[str, str, str]] = set()\n\
942 other = set()\n",
943 )
944 .unwrap();
945 let result = scan_producer_suppression_sites(&path);
946 assert_eq!(result.total_sites, 2);
947 assert_eq!(result.sites[0].name, "seen_call_pairs");
948 assert_eq!(result.sites[0].tuple_arity, 2);
949 assert_eq!(result.sites[1].tuple_arity, 3);
950 }
951
952 #[test]
953 fn test_scan_producer_suppression_sites_handles_unknown_tuple_arity() {
954 let dir = tempfile::tempdir().unwrap();
955 let path = dir.path().join("extract.py");
956 std::fs::write(&path, "seen_blank: set[tuple[ ]] = set()\n").unwrap();
957 let result = scan_producer_suppression_sites(&path);
958 assert_eq!(result.total_sites, 1);
959 assert_eq!(result.sites[0].tuple_arity, 0);
960 }
961
962 #[test]
963 fn test_diagnose_file_rejects_oversized_graph() {
964 let dir = tempfile::tempdir().unwrap();
965 let path = dir.path().join("graph.json");
966 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
967 let result = diagnose_file(&path, None, 5, None);
972 assert!(result.is_ok());
973 }
974
975 #[test]
976 fn test_diagnose_file_rejects_non_object_json() {
977 let dir = tempfile::tempdir().unwrap();
978 let path = dir.path().join("graph.json");
979 std::fs::write(&path, "[]").unwrap();
980 let result = diagnose_file(&path, None, 5, None);
981 assert!(result.is_err());
982 assert!(result.unwrap_err().to_string().contains("JSON object"));
983 }
984
985 #[test]
986 fn test_diagnose_file_defaults_to_json_directed_flag() {
987 let dir = tempfile::tempdir().unwrap();
988 let path = dir.path().join("graph.json");
989 let mut payload = diagnostic_fixture();
990 payload["directed"] = json!(false);
991 std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
992 let summary = diagnose_file(&path, None, 5, None).unwrap();
993 assert_eq!(summary.effective_directed, Some(false));
994 assert_eq!(summary.post_build_graph_type, "Graph");
995 }
996
997 #[test]
998 fn test_diagnose_file_explicit_directed_override() {
999 let dir = tempfile::tempdir().unwrap();
1000 let path = dir.path().join("graph.json");
1001 let mut payload = diagnostic_fixture();
1002 payload["directed"] = json!(false);
1003 std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
1004 let summary = diagnose_file(&path, Some(true), 5, None).unwrap();
1005 assert_eq!(summary.effective_directed, Some(true));
1006 assert_eq!(summary.post_build_graph_type, "DiGraph");
1007 }
1008
1009 #[test]
1010 fn test_scan_producer_suppression_sites_reports_missing_file() {
1011 let result =
1012 scan_producer_suppression_sites(Path::new("/tmp/nonexistent-extract-12345.py"));
1013 assert_eq!(result.total_sites, 0);
1014 assert!(result.sites.is_empty());
1015 assert_eq!(result.error, "file not found");
1016 }
1017
1018 #[test]
1019 fn test_diagnose_multigraph_cli_human_output() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let path = dir.path().join("graph.json");
1022 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
1023 let summary = diagnose_file(&path, None, 5, None).unwrap();
1024 let report = format_diagnostic_report(&summary);
1025 assert!(
1026 report.contains("[codesynapse] MultiDiGraph edge-collapse diagnostic"),
1027 "{report}"
1028 );
1029 assert!(report.contains("raw_edges: 7"), "{report}");
1030 assert!(report.contains("effective_directed: true"), "{report}");
1031 assert!(
1032 report.contains("directed_same_endpoint_collapsed_edges: 3"),
1033 "{report}"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_diagnose_multigraph_cli_undirected_override() {
1039 let dir = tempfile::tempdir().unwrap();
1040 let path = dir.path().join("graph.json");
1041 let mut payload = diagnostic_fixture();
1042 payload["directed"] = json!(true);
1043 std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
1044 let summary = diagnose_file(&path, Some(false), 5, None).unwrap();
1045 let report = format_diagnostic_report(&summary);
1046 assert!(report.contains("effective_directed: false"), "{report}");
1047 assert!(report.contains("post_build_graph_type: Graph"), "{report}");
1048 }
1049
1050 #[test]
1051 fn test_diagnose_multigraph_cli_max_examples_zero() {
1052 let dir = tempfile::tempdir().unwrap();
1053 let path = dir.path().join("graph.json");
1054 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
1055 let summary = diagnose_file(&path, None, 0, None).unwrap();
1056 let report = format_diagnostic_report(&summary);
1057 assert!(
1058 !report.contains("\nexamples:"),
1059 "examples should not appear: {report}"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_diagnose_multigraph_cli_json_output() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let path = dir.path().join("graph.json");
1067 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
1068 let summary = diagnose_file(&path, None, 5, None).unwrap();
1069 let payload = format_diagnostic_json(&summary);
1070 assert_eq!(payload["schema_version"], 1);
1071 assert_eq!(
1072 payload["summary"]["directed_same_endpoint_collapsed_edges"],
1073 3
1074 );
1075 }
1076
1077 #[test]
1078 fn test_diagnose_multigraph_cli_usage_errors() {
1079 let dir = tempfile::tempdir().unwrap();
1082 let path = dir.path().join("graph.json");
1083 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
1084 let summary = diagnose_file(&path, None, 0, None).unwrap();
1086 assert!(summary.examples.is_empty());
1087 }
1088
1089 #[test]
1090 fn test_diagnose_multigraph_cli_rejects_conflicting_direction_flags() {
1091 let dir = tempfile::tempdir().unwrap();
1094 let path = dir.path().join("graph.json");
1095 std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
1096 let s_dir = diagnose_file(&path, Some(true), 5, None).unwrap();
1097 let s_undir = diagnose_file(&path, Some(false), 5, None).unwrap();
1098 assert_eq!(s_dir.post_build_graph_type, "DiGraph");
1099 assert_eq!(s_undir.post_build_graph_type, "Graph");
1100 }
1101}