use regex::Regex;
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::OnceLock;
const MAX_GRAPH_FILE_BYTES: u64 = 512 * 1024 * 1024;
fn suppression_decl_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\s*(?P<name>seen_[A-Za-z0-9_]+)\s*[:=]").unwrap())
}
fn type_tuple_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"set\[tuple\[(?P<inside>[^\]]+)\]\]").unwrap())
}
fn safe_text(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(other) => serde_json::to_string(other).unwrap_or_default(),
}
}
fn edge_list(extraction: &Value) -> Vec<Value> {
let obj = match extraction.as_object() {
Some(o) => o,
None => return vec![],
};
let edges = obj
.get("edges")
.filter(|v| v.is_array())
.or_else(|| obj.get("links").filter(|v| v.is_array()));
match edges {
Some(Value::Array(arr)) => arr.clone(),
_ => vec![],
}
}
fn node_ids(extraction: &Value) -> HashSet<String> {
let arr = match extraction.get("nodes").and_then(|v| v.as_array()) {
Some(a) => a,
None => return HashSet::new(),
};
arr.iter()
.filter_map(|node| {
let obj = node.as_object()?;
let id = obj.get("id")?;
if id.is_null() {
return None;
}
Some(safe_text(Some(id)))
})
.filter(|s| !s.is_empty())
.collect()
}
#[derive(Debug, Clone)]
struct CanonEdge {
source: String,
target: String,
relation: String,
#[allow(dead_code)]
confidence: String,
source_file: String,
source_location: String,
context: String,
invalid: String,
}
impl CanonEdge {
fn field(&self, name: &str) -> String {
match name {
"relation" => self.relation.clone(),
"source_file" => self.source_file.clone(),
"source_location" => self.source_location.clone(),
"context" => self.context.clone(),
_ => String::new(),
}
}
}
fn canonical_edge(edge: &Value) -> CanonEdge {
match edge.as_object() {
None => CanonEdge {
source: String::new(),
target: String::new(),
relation: String::new(),
confidence: String::new(),
source_file: String::new(),
source_location: String::new(),
context: String::new(),
invalid: "non_object_edge".to_string(),
},
Some(obj) => {
let source = safe_text(
obj.get("source")
.or_else(|| obj.get("from"))
.filter(|v| !v.is_null()),
);
let target = safe_text(
obj.get("target")
.or_else(|| obj.get("to"))
.filter(|v| !v.is_null()),
);
CanonEdge {
source,
target,
relation: safe_text(obj.get("relation")),
confidence: safe_text(obj.get("confidence")),
source_file: safe_text(obj.get("source_file")),
source_location: safe_text(obj.get("source_location")),
context: safe_text(obj.get("context")),
invalid: String::new(),
}
}
}
}
fn exact_signature(edge: &Value) -> String {
let obj = match edge.as_object() {
Some(o) => o,
None => return "<non-object>".to_string(),
};
let mut normalized: Map<String, Value> = Map::new();
for (k, v) in obj {
if k == "from" && !obj.contains_key("source") {
normalized.insert("source".to_string(), v.clone());
} else if k == "to" && !obj.contains_key("target") {
normalized.insert("target".to_string(), v.clone());
} else if k != "from" && k != "to" {
normalized.insert(k.clone(), v.clone());
}
}
let mut sorted: Vec<(String, Value)> = normalized.into_iter().collect();
sorted.sort_by_key(|k| k.0.clone());
let sorted_obj: Map<String, Value> = sorted.into_iter().collect();
serde_json::to_string(&Value::Object(sorted_obj)).unwrap_or_default()
}
fn count_extra(counts: &HashMap<String, usize>) -> usize {
counts.values().filter(|&&c| c > 1).map(|&c| c - 1).sum()
}
fn variant_group_count(
grouped: &HashMap<(String, String), Vec<CanonEdge>>,
field: &str,
relation_sensitive: bool,
) -> usize {
let mut groups = 0;
for edges in grouped.values() {
if relation_sensitive {
let mut by_relation: HashMap<&str, HashSet<String>> = HashMap::new();
for edge in edges {
by_relation
.entry(edge.relation.as_str())
.or_default()
.insert(edge.field(field));
}
groups += by_relation.values().filter(|vals| vals.len() > 1).count();
} else {
let vals: HashSet<String> = edges.iter().map(|e| e.field(field)).collect();
if vals.len() > 1 {
groups += 1;
}
}
}
groups
}
fn tuple_arity_from_annotation(line: &str) -> usize {
let re = type_tuple_re();
match re.captures(line) {
None => 0,
Some(caps) => {
let inside = caps["inside"].trim();
if inside.is_empty() {
0
} else {
inside.chars().filter(|&c| c == ',').count() + 1
}
}
}
}
#[derive(Debug, Clone)]
pub struct SuppressionSite {
pub line: usize,
pub name: String,
pub tuple_arity: usize,
pub sample: String,
}
#[derive(Debug, Clone)]
pub struct SuppressionResult {
pub path: String,
pub total_sites: usize,
pub sites: Vec<SuppressionSite>,
pub error: String,
}
pub fn scan_producer_suppression_sites(path: &Path) -> SuppressionResult {
if !path.exists() {
return SuppressionResult {
path: path.display().to_string(),
total_sites: 0,
sites: vec![],
error: "file not found".to_string(),
};
}
let text = match std::fs::read_to_string(path) {
Err(e) => {
return SuppressionResult {
path: path.display().to_string(),
total_sites: 0,
sites: vec![],
error: e.to_string(),
}
}
Ok(t) => t,
};
let re = suppression_decl_re();
let mut sites: Vec<SuppressionSite> = Vec::new();
for (i, line) in text.lines().enumerate() {
if let Some(caps) = re.captures(line) {
let name = caps["name"].to_string();
let arity = tuple_arity_from_annotation(line);
let sample = line.trim().chars().take(120).collect();
sites.push(SuppressionSite {
line: i + 1,
name,
tuple_arity: arity,
sample,
});
}
}
SuppressionResult {
path: path.display().to_string(),
total_sites: sites.len(),
sites,
error: String::new(),
}
}
#[derive(Debug, Clone)]
pub struct EdgeGroupExample {
pub source: String,
pub target: String,
pub edge_count: usize,
pub relations: Vec<String>,
pub source_files: Vec<String>,
pub source_locations: Vec<String>,
pub contexts: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DiagnosticSummary {
pub node_count: usize,
pub raw_edge_count: usize,
pub non_object_edges: usize,
pub missing_endpoint_edges: usize,
pub dangling_endpoint_edges: usize,
pub self_loop_edges: usize,
pub valid_candidate_edges: usize,
pub exact_duplicate_edges: usize,
pub directed_unique_endpoint_pairs: usize,
pub directed_same_endpoint_collapsed_edges: usize,
pub undirected_unique_endpoint_pairs: usize,
pub undirected_same_endpoint_collapsed_edges: usize,
pub same_endpoint_group_count: usize,
pub relation_variant_groups: usize,
pub source_file_variant_groups: usize,
pub source_location_variant_groups: usize,
pub context_variant_groups: usize,
pub post_build_graph_type: String,
pub post_build_node_count: Option<usize>,
pub post_build_edge_count: Option<usize>,
pub post_build_error: String,
pub producer_suppression: SuppressionResult,
pub examples: Vec<EdgeGroupExample>,
pub input_path: Option<String>,
pub effective_directed: Option<bool>,
}
fn simulate_post_build(
valid_edges: &[(String, String)],
node_ids: &HashSet<String>,
directed: bool,
) -> (String, Option<usize>, Option<usize>, String) {
let graph_type = if directed { "DiGraph" } else { "Graph" };
if directed {
let unique: HashSet<(&str, &str)> = valid_edges
.iter()
.map(|(s, t)| (s.as_str(), t.as_str()))
.collect();
let unique_nodes: HashSet<&str> = valid_edges
.iter()
.flat_map(|(s, t)| [s.as_str(), t.as_str()])
.filter(|id| node_ids.contains(*id))
.collect();
(
graph_type.to_string(),
Some(unique_nodes.len()),
Some(unique.len()),
String::new(),
)
} else {
let unique: HashSet<(String, String)> = valid_edges
.iter()
.map(|(s, t)| {
if s <= t {
(s.clone(), t.clone())
} else {
(t.clone(), s.clone())
}
})
.collect();
let unique_nodes: HashSet<&str> = valid_edges
.iter()
.flat_map(|(s, t)| [s.as_str(), t.as_str()])
.filter(|id| node_ids.contains(*id))
.collect();
(
graph_type.to_string(),
Some(unique_nodes.len()),
Some(unique.len()),
String::new(),
)
}
}
pub fn diagnose_extraction(
extraction: &Value,
directed: bool,
max_examples: usize,
extract_path: Option<&Path>,
) -> DiagnosticSummary {
let ids = node_ids(extraction);
let raw_edges = edge_list(extraction);
let canonical: Vec<CanonEdge> = raw_edges.iter().map(canonical_edge).collect();
let mut exact_counts: HashMap<String, usize> = HashMap::new();
let mut directed_pair_counts: HashMap<(String, String), usize> = HashMap::new();
let mut undirected_pair_counts: HashMap<(String, String), usize> = HashMap::new();
let mut grouped: HashMap<(String, String), Vec<CanonEdge>> = HashMap::new();
let mut non_object_edges = 0usize;
let mut missing_endpoint_edges = 0usize;
let mut dangling_endpoint_edges = 0usize;
let mut self_loop_edges = 0usize;
let mut valid_candidate_edges = 0usize;
let mut valid_pairs: Vec<(String, String)> = Vec::new();
let has_non_dict_node = extraction
.get("nodes")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|n| !n.is_object()))
.unwrap_or(false);
for (i, raw_edge) in raw_edges.iter().enumerate() {
let canon = &canonical[i];
if !canon.invalid.is_empty() {
non_object_edges += 1;
continue;
}
if canon.source.is_empty() || canon.target.is_empty() {
missing_endpoint_edges += 1;
continue;
}
if !ids.contains(&canon.source) || !ids.contains(&canon.target) {
dangling_endpoint_edges += 1;
continue;
}
if canon.source == canon.target {
self_loop_edges += 1;
}
valid_candidate_edges += 1;
valid_pairs.push((canon.source.clone(), canon.target.clone()));
let sig = exact_signature(raw_edge);
*exact_counts.entry(sig).or_insert(0) += 1;
let dir_pair = (canon.source.clone(), canon.target.clone());
*directed_pair_counts.entry(dir_pair.clone()).or_insert(0) += 1;
grouped.entry(dir_pair).or_default().push(canon.clone());
let (a, b) = if canon.source <= canon.target {
(canon.source.clone(), canon.target.clone())
} else {
(canon.target.clone(), canon.source.clone())
};
*undirected_pair_counts.entry((a, b)).or_insert(0) += 1;
}
let exact_duplicate_edges = count_extra(&exact_counts);
let directed_unique = directed_pair_counts.len();
let directed_collapsed = count_extra(
&directed_pair_counts
.iter()
.map(|(k, v)| (format!("{}->{}", k.0, k.1), *v))
.collect(),
);
let undirected_unique = undirected_pair_counts.len();
let undirected_collapsed = count_extra(
&undirected_pair_counts
.iter()
.map(|(k, v)| (format!("{}<>{}", k.0, k.1), *v))
.collect(),
);
let same_endpoint_group_count = directed_pair_counts.values().filter(|&&c| c > 1).count();
let relation_variant_groups = variant_group_count(&grouped, "relation", false);
let source_file_variant_groups = variant_group_count(&grouped, "source_file", true);
let source_location_variant_groups = variant_group_count(&grouped, "source_location", true);
let context_variant_groups = variant_group_count(&grouped, "context", true);
let post_build_error = if has_non_dict_node {
"TypeError: non-object node in nodes list".to_string()
} else {
String::new()
};
let (post_build_graph_type, post_build_node_count, post_build_edge_count, _) =
if post_build_error.is_empty() {
simulate_post_build(&valid_pairs, &ids, directed)
} else {
(String::new(), None, None, String::new())
};
let suppression_path = extract_path
.map(|p| p.to_path_buf())
.unwrap_or_else(|| Path::new("extract.py").to_path_buf());
let mut examples: Vec<EdgeGroupExample> = Vec::new();
if max_examples > 0 {
let mut pairs_by_count: Vec<(&(String, String), usize)> =
directed_pair_counts.iter().map(|(k, v)| (k, *v)).collect();
pairs_by_count.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (pair, count) in pairs_by_count {
if count < 2 {
continue;
}
if examples.len() >= max_examples {
break;
}
if let Some(edges) = grouped.get(pair) {
let mut relations: Vec<String> = edges
.iter()
.map(|e| e.relation.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
relations.sort();
let mut source_files: Vec<String> = edges
.iter()
.map(|e| e.source_file.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
source_files.sort();
let mut source_locations: Vec<String> = edges
.iter()
.map(|e| e.source_location.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
source_locations.sort();
let mut contexts: Vec<String> = edges
.iter()
.map(|e| e.context.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
contexts.sort();
examples.push(EdgeGroupExample {
source: pair.0.clone(),
target: pair.1.clone(),
edge_count: count,
relations,
source_files,
source_locations,
contexts,
});
}
}
}
DiagnosticSummary {
node_count: ids.len(),
raw_edge_count: raw_edges.len(),
non_object_edges,
missing_endpoint_edges,
dangling_endpoint_edges,
self_loop_edges,
valid_candidate_edges,
exact_duplicate_edges,
directed_unique_endpoint_pairs: directed_unique,
directed_same_endpoint_collapsed_edges: directed_collapsed,
undirected_unique_endpoint_pairs: undirected_unique,
undirected_same_endpoint_collapsed_edges: undirected_collapsed,
same_endpoint_group_count,
relation_variant_groups,
source_file_variant_groups,
source_location_variant_groups,
context_variant_groups,
post_build_graph_type,
post_build_node_count,
post_build_edge_count,
post_build_error,
producer_suppression: scan_producer_suppression_sites(&suppression_path),
examples,
input_path: None,
effective_directed: None,
}
}
pub fn diagnose_file(
path: &Path,
directed: Option<bool>,
max_examples: usize,
extract_path: Option<&Path>,
) -> crate::error::Result<DiagnosticSummary> {
let size = std::fs::metadata(path)?.len();
if size > MAX_GRAPH_FILE_BYTES {
return Err(crate::error::CodeSynapseError::Validation(format!(
"graph file exceeds {} byte limit",
MAX_GRAPH_FILE_BYTES
)));
}
let text = std::fs::read_to_string(path)?;
let data: Value = serde_json::from_str(&text)
.map_err(|e| crate::error::CodeSynapseError::Validation(format!("invalid JSON: {}", e)))?;
if !data.is_object() {
return Err(crate::error::CodeSynapseError::Validation(
"diagnostic input must be a JSON object".to_string(),
));
}
let effective_directed = match directed {
Some(d) => d,
None => data
.get("directed")
.and_then(|v| v.as_bool())
.unwrap_or(true),
};
let mut summary = diagnose_extraction(&data, effective_directed, max_examples, extract_path);
summary.input_path = Some(path.display().to_string());
summary.effective_directed = Some(effective_directed);
Ok(summary)
}
pub fn format_diagnostic_report(summary: &DiagnosticSummary) -> String {
let suppression = &summary.producer_suppression;
let mut lines = vec![
"[codesynapse] MultiDiGraph edge-collapse diagnostic".to_string(),
format!(
"input: {}",
summary.input_path.as_deref().unwrap_or("<in-memory>")
),
"input_stage: provided JSON (normal graph.json is post-build)".to_string(),
format!(
"effective_directed: {}",
summary
.effective_directed
.map(|d| d.to_string())
.unwrap_or_else(|| "<direct-call>".to_string())
),
format!("nodes: {}", summary.node_count),
format!("raw_edges: {}", summary.raw_edge_count),
format!("valid_candidate_edges: {}", summary.valid_candidate_edges),
format!("missing_endpoint_edges: {}", summary.missing_endpoint_edges),
format!(
"dangling_endpoint_edges: {}",
summary.dangling_endpoint_edges
),
format!("self_loop_edges: {}", summary.self_loop_edges),
format!("exact_duplicate_edges: {}", summary.exact_duplicate_edges),
format!(
"directed_unique_endpoint_pairs: {}",
summary.directed_unique_endpoint_pairs
),
format!(
"directed_same_endpoint_collapsed_edges: {}",
summary.directed_same_endpoint_collapsed_edges
),
format!(
"undirected_unique_endpoint_pairs: {}",
summary.undirected_unique_endpoint_pairs
),
format!(
"undirected_same_endpoint_collapsed_edges: {}",
summary.undirected_same_endpoint_collapsed_edges
),
format!(
"same_endpoint_group_count: {}",
summary.same_endpoint_group_count
),
format!(
"relation_variant_groups: {}",
summary.relation_variant_groups
),
format!(
"source_file_variant_groups: {}",
summary.source_file_variant_groups
),
format!(
"source_location_variant_groups: {}",
summary.source_location_variant_groups
),
format!("context_variant_groups: {}", summary.context_variant_groups),
format!("post_build_graph_type: {}", summary.post_build_graph_type),
format!(
"post_build_edges: {}",
summary
.post_build_edge_count
.map(|n| n.to_string())
.unwrap_or_else(|| "None".to_string())
),
format!("producer_suppression_sites: {}", suppression.total_sites),
];
if !summary.post_build_error.is_empty() {
lines.push(format!("post_build_error: {}", summary.post_build_error));
}
if !suppression.error.is_empty() {
lines.push(format!("producer_suppression_error: {}", suppression.error));
}
if !suppression.sites.is_empty() {
lines.push("producer_suppression_examples:".to_string());
for site in suppression.sites.iter().take(8) {
lines.push(format!(
" - L{} {} arity={}",
site.line,
site.name,
if site.tuple_arity == 0 {
"unknown".to_string()
} else {
site.tuple_arity.to_string()
}
));
}
}
if !summary.examples.is_empty() {
lines.push("examples:".to_string());
for ex in &summary.examples {
lines.push(format!(
" - {} -> {} edges={} relations={:?} locations={:?} contexts={:?}",
ex.source, ex.target, ex.edge_count, ex.relations, ex.source_locations, ex.contexts
));
}
}
lines.push(
"note: normal graph.json is post-build; raw producer loss must be measured earlier."
.to_string(),
);
lines.join("\n")
}
pub fn format_diagnostic_json(summary: &DiagnosticSummary) -> Value {
let suppression = &summary.producer_suppression;
let summary_obj = json!({
"node_count": summary.node_count,
"raw_edge_count": summary.raw_edge_count,
"non_object_edges": summary.non_object_edges,
"missing_endpoint_edges": summary.missing_endpoint_edges,
"dangling_endpoint_edges": summary.dangling_endpoint_edges,
"self_loop_edges": summary.self_loop_edges,
"valid_candidate_edges": summary.valid_candidate_edges,
"exact_duplicate_edges": summary.exact_duplicate_edges,
"directed_unique_endpoint_pairs": summary.directed_unique_endpoint_pairs,
"directed_same_endpoint_collapsed_edges": summary.directed_same_endpoint_collapsed_edges,
"undirected_unique_endpoint_pairs": summary.undirected_unique_endpoint_pairs,
"undirected_same_endpoint_collapsed_edges": summary.undirected_same_endpoint_collapsed_edges,
"same_endpoint_group_count": summary.same_endpoint_group_count,
"relation_variant_groups": summary.relation_variant_groups,
"source_file_variant_groups": summary.source_file_variant_groups,
"source_location_variant_groups": summary.source_location_variant_groups,
"context_variant_groups": summary.context_variant_groups,
"post_build_graph_type": summary.post_build_graph_type,
"post_build_node_count": summary.post_build_node_count,
"post_build_edge_count": summary.post_build_edge_count,
"post_build_error": summary.post_build_error,
"input_path": summary.input_path,
"effective_directed": summary.effective_directed,
});
let suppression_obj = json!({
"path": suppression.path,
"total_sites": suppression.total_sites,
"sites": suppression.sites.iter().map(|s| json!({
"line": s.line,
"name": s.name,
"tuple_arity": s.tuple_arity,
"sample": s.sample,
})).collect::<Vec<_>>(),
"error": suppression.error,
});
let examples_arr: Vec<Value> = summary
.examples
.iter()
.map(|ex| {
json!({
"source": ex.source,
"target": ex.target,
"edge_count": ex.edge_count,
"relations": ex.relations,
"source_files": ex.source_files,
"source_locations": ex.source_locations,
"contexts": ex.contexts,
})
})
.collect();
json!({
"schema_version": 1,
"summary": summary_obj,
"examples": examples_arr,
"producer_suppression": suppression_obj,
"notes": [
"Diagnostics are read-only.",
"A normal graph.json is already post-build and cannot recover raw producer edges.",
"Producer suppression sites are heuristic source-code evidence.",
],
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn diagnostic_fixture() -> Value {
json!({
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
{"id": "c", "label": "C", "file_type": "code", "source_file": "c.py"},
],
"edges": [
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
"source_file": "a.py", "source_location": "L1", "context": "call"},
{"source": "a", "target": "b", "relation": "imports", "confidence": "EXTRACTED",
"source_file": "a.py", "source_location": "L2", "context": "import"},
{"source": "a", "target": "b", "relation": "calls", "confidence": "INFERRED",
"source_file": "a.py", "source_location": "L3", "context": "call"},
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
"source_file": "a.py", "source_location": "L1", "context": "call"},
{"source": "a", "target": "missing", "relation": "calls", "confidence": "EXTRACTED",
"source_file": "a.py"},
{"source": "a", "relation": "calls", "confidence": "EXTRACTED", "source_file": "a.py"},
{"source": "c", "target": "c", "relation": "references", "confidence": "EXTRACTED",
"source_file": "c.py"},
],
})
}
#[test]
fn test_diagnose_extraction_categorizes_same_endpoint_collapse() {
let summary = diagnose_extraction(&diagnostic_fixture(), true, 5, None);
assert_eq!(summary.node_count, 3);
assert_eq!(summary.raw_edge_count, 7);
assert_eq!(summary.valid_candidate_edges, 5);
assert_eq!(summary.missing_endpoint_edges, 1);
assert_eq!(summary.dangling_endpoint_edges, 1);
assert_eq!(summary.self_loop_edges, 1);
assert_eq!(summary.exact_duplicate_edges, 1);
assert_eq!(summary.directed_unique_endpoint_pairs, 2);
assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
assert_eq!(summary.same_endpoint_group_count, 1);
assert_eq!(summary.relation_variant_groups, 1);
assert_eq!(summary.source_location_variant_groups, 1);
assert_eq!(summary.post_build_graph_type, "DiGraph");
assert_eq!(summary.post_build_edge_count, Some(2));
}
#[test]
fn test_diagnose_extraction_accepts_node_link_links_key() {
let mut extraction = diagnostic_fixture();
let edges = extraction["edges"].take();
extraction["links"] = edges;
let summary = diagnose_extraction(&extraction, true, 5, None);
assert_eq!(summary.raw_edge_count, 7);
assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
}
#[test]
fn test_diagnose_extraction_does_not_mutate_input() {
let extraction = diagnostic_fixture();
let original = extraction.clone();
diagnose_extraction(&extraction, true, 5, None);
assert_eq!(extraction, original);
}
#[test]
fn test_diagnose_extraction_handles_malformed_shapes_without_crashing() {
let extraction = json!({
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
["not", "a", "node"],
{"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
],
"edges": [
null,
["not", "an", "edge"],
{"from": "a", "to": "b", "relation": "legacy_from_to"},
{"source": "a", "target": {"unhashable": "target"}, "relation": "bad-target"},
{"source": "a", "target": "missing", "relation": "dangling"},
{"source": "", "target": "b", "relation": "missing-source"},
],
});
let summary = diagnose_extraction(&extraction, true, 5, None);
assert_eq!(summary.node_count, 2);
assert_eq!(summary.raw_edge_count, 6);
assert_eq!(summary.non_object_edges, 2);
assert_eq!(summary.missing_endpoint_edges, 1);
assert_eq!(summary.dangling_endpoint_edges, 2);
assert_eq!(summary.valid_candidate_edges, 1);
assert!(
summary.post_build_error.starts_with("TypeError:"),
"expected TypeError prefix: {}",
summary.post_build_error
);
}
#[test]
fn test_diagnose_extraction_handles_non_list_nodes_and_edges() {
let extraction = json!({
"nodes": {"id": "a"},
"edges": {"source": "a", "target": "b"},
});
let summary = diagnose_extraction(&extraction, true, 5, None);
assert_eq!(summary.node_count, 0);
assert_eq!(summary.raw_edge_count, 0);
assert_eq!(summary.valid_candidate_edges, 0);
}
#[test]
fn test_diagnose_extraction_bounds_examples() {
let summary = diagnose_extraction(&diagnostic_fixture(), true, 0, None);
assert_eq!(summary.directed_same_endpoint_collapsed_edges, 3);
assert!(summary.examples.is_empty());
}
#[test]
fn test_diagnose_extraction_stops_examples_at_requested_limit() {
let mut extraction = diagnostic_fixture();
extraction["nodes"]
.as_array_mut()
.unwrap()
.push(json!({"id": "d", "label": "D", "file_type": "code", "source_file": "d.py"}));
extraction["edges"].as_array_mut().unwrap().extend(vec![
json!({"source": "b", "target": "d", "relation": "imports", "source_file": "b.py"}),
json!({"source": "b", "target": "d", "relation": "calls", "source_file": "b.py"}),
]);
let summary = diagnose_extraction(&extraction, true, 1, None);
assert_eq!(summary.same_endpoint_group_count, 2);
assert_eq!(summary.examples.len(), 1);
}
#[test]
fn test_diagnose_extraction_defaults_raw_inputs_to_directed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("raw.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 5, None).unwrap();
assert_eq!(summary.effective_directed, Some(true));
assert_eq!(summary.post_build_graph_type, "DiGraph");
}
#[test]
fn test_diagnose_file_reads_json_and_formats_report() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, Some(true), 2, None).unwrap();
let report = format_diagnostic_report(&summary);
assert_eq!(
summary.input_path.as_deref().unwrap(),
path.to_str().unwrap()
);
assert!(
report.contains("[codesynapse] MultiDiGraph edge-collapse diagnostic"),
"{report}"
);
assert!(
report.contains("directed_same_endpoint_collapsed_edges: 3"),
"{report}"
);
assert!(report.contains("relation_variant_groups: 1"), "{report}");
assert!(report.contains("producer_suppression_sites:"), "{report}");
assert!(report.contains("examples:"), "{report}");
assert!(report.contains("a -> b"), "{report}");
}
#[test]
fn test_format_diagnostic_report_includes_build_and_suppression_errors() {
let dir = tempfile::tempdir().unwrap();
let extraction = json!({
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
["not", "a", "node"],
],
"edges": [],
});
let summary =
diagnose_extraction(&extraction, true, 5, Some(&dir.path().join("missing.py")));
let report = format_diagnostic_report(&summary);
assert!(report.contains("post_build_error: TypeError:"), "{report}");
assert!(
report.contains("producer_suppression_error: file not found"),
"{report}"
);
}
#[test]
fn test_diagnostic_json_report_is_serializable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, Some(true), 5, None).unwrap();
let payload = format_diagnostic_json(&summary);
assert_eq!(payload["schema_version"], 1);
assert_eq!(payload["summary"]["raw_edge_count"], 7);
assert!(payload.get("producer_suppression").is_some());
serde_json::to_string(&payload).unwrap();
}
#[test]
fn test_scan_producer_suppression_sites_finds_seen_sets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extract.py");
std::fs::write(
&path,
"seen_call_pairs: set[tuple[str, str]] = set()\n\
seen_static_ref_pairs: set[tuple[str, str, str]] = set()\n\
other = set()\n",
)
.unwrap();
let result = scan_producer_suppression_sites(&path);
assert_eq!(result.total_sites, 2);
assert_eq!(result.sites[0].name, "seen_call_pairs");
assert_eq!(result.sites[0].tuple_arity, 2);
assert_eq!(result.sites[1].tuple_arity, 3);
}
#[test]
fn test_scan_producer_suppression_sites_handles_unknown_tuple_arity() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extract.py");
std::fs::write(&path, "seen_blank: set[tuple[ ]] = set()\n").unwrap();
let result = scan_producer_suppression_sites(&path);
assert_eq!(result.total_sites, 1);
assert_eq!(result.sites[0].tuple_arity, 0);
}
#[test]
fn test_diagnose_file_rejects_oversized_graph() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let result = diagnose_file(&path, None, 5, None);
assert!(result.is_ok());
}
#[test]
fn test_diagnose_file_rejects_non_object_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, "[]").unwrap();
let result = diagnose_file(&path, None, 5, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("JSON object"));
}
#[test]
fn test_diagnose_file_defaults_to_json_directed_flag() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
let mut payload = diagnostic_fixture();
payload["directed"] = json!(false);
std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 5, None).unwrap();
assert_eq!(summary.effective_directed, Some(false));
assert_eq!(summary.post_build_graph_type, "Graph");
}
#[test]
fn test_diagnose_file_explicit_directed_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
let mut payload = diagnostic_fixture();
payload["directed"] = json!(false);
std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let summary = diagnose_file(&path, Some(true), 5, None).unwrap();
assert_eq!(summary.effective_directed, Some(true));
assert_eq!(summary.post_build_graph_type, "DiGraph");
}
#[test]
fn test_scan_producer_suppression_sites_reports_missing_file() {
let result =
scan_producer_suppression_sites(Path::new("/tmp/nonexistent-extract-12345.py"));
assert_eq!(result.total_sites, 0);
assert!(result.sites.is_empty());
assert_eq!(result.error, "file not found");
}
#[test]
fn test_diagnose_multigraph_cli_human_output() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 5, None).unwrap();
let report = format_diagnostic_report(&summary);
assert!(
report.contains("[codesynapse] MultiDiGraph edge-collapse diagnostic"),
"{report}"
);
assert!(report.contains("raw_edges: 7"), "{report}");
assert!(report.contains("effective_directed: true"), "{report}");
assert!(
report.contains("directed_same_endpoint_collapsed_edges: 3"),
"{report}"
);
}
#[test]
fn test_diagnose_multigraph_cli_undirected_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
let mut payload = diagnostic_fixture();
payload["directed"] = json!(true);
std::fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let summary = diagnose_file(&path, Some(false), 5, None).unwrap();
let report = format_diagnostic_report(&summary);
assert!(report.contains("effective_directed: false"), "{report}");
assert!(report.contains("post_build_graph_type: Graph"), "{report}");
}
#[test]
fn test_diagnose_multigraph_cli_max_examples_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 0, None).unwrap();
let report = format_diagnostic_report(&summary);
assert!(
!report.contains("\nexamples:"),
"examples should not appear: {report}"
);
}
#[test]
fn test_diagnose_multigraph_cli_json_output() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 5, None).unwrap();
let payload = format_diagnostic_json(&summary);
assert_eq!(payload["schema_version"], 1);
assert_eq!(
payload["summary"]["directed_same_endpoint_collapsed_edges"],
3
);
}
#[test]
fn test_diagnose_multigraph_cli_usage_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let summary = diagnose_file(&path, None, 0, None).unwrap();
assert!(summary.examples.is_empty());
}
#[test]
fn test_diagnose_multigraph_cli_rejects_conflicting_direction_flags() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
std::fs::write(&path, serde_json::to_string(&diagnostic_fixture()).unwrap()).unwrap();
let s_dir = diagnose_file(&path, Some(true), 5, None).unwrap();
let s_undir = diagnose_file(&path, Some(false), 5, None).unwrap();
assert_eq!(s_dir.post_build_graph_type, "DiGraph");
assert_eq!(s_undir.post_build_graph_type, "Graph");
}
}