use super::layout;
use crate::engine::{RepositoryState, Weavatrix};
use crate::operations;
use blazingly_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use weavatrix_graph::{EdgeKind, NodeKind};
const MAX_MODULES: usize = 40;
const MAX_ROWS: u64 = 20;
const MIN_MODULES: usize = 5;
pub(super) struct Module {
pub path: String,
pub files: u64,
pub symbols: u64,
pub violations: u64,
pub position: (f64, f64),
pub drawn: bool,
}
pub(super) struct Evidence {
pub stats: Value,
pub architecture: Value,
pub hubs: Value,
pub hot: Value,
pub dead: Value,
pub duplicates: Value,
pub endpoints: Value,
pub workflows: Value,
pub apps: Value,
pub modules: Vec<Module>,
pub links: Vec<layout::Link>,
pub depth: usize,
}
pub(super) fn gather(engine: &mut Weavatrix) -> Result<Evidence, String> {
let stats = section(engine, "graph_stats", &json!({}))?;
let architecture = section(engine, "verify_architecture", &json!({}))?;
let (module_map, depth) = grouped_modules(engine)?;
let hubs = section(engine, "god_nodes", &json!({"top_n": MAX_ROWS}))?;
let hot = section(engine, "hot_path_review", &json!({"top_n": MAX_ROWS}))?;
let dead = section(engine, "find_dead_code", &json!({"top_n": MAX_ROWS}))?;
let duplicates = section(engine, "find_duplicates", &json!({"top_n": MAX_ROWS}))?;
let endpoints = section(engine, "list_endpoints", &json!({"max_results": MAX_ROWS}))?;
let workflows = section(engine, "n8n_inventory", &json!({"max_results": MAX_ROWS}))?;
let apps = section(engine, "dify_inventory", &json!({"max_results": MAX_ROWS}))?;
let (modules, links) = module_graph(engine, &module_map, &architecture, depth);
Ok(Evidence {
stats,
architecture,
hubs,
hot,
dead,
duplicates,
endpoints,
workflows,
apps,
modules,
links,
depth,
})
}
fn grouped_modules(engine: &mut Weavatrix) -> Result<(Value, usize), String> {
let shallow = section(engine, "module_map", &json!({"top_n": MAX_MODULES}))?;
if shallow["modules"].as_array().map_or(0, Vec::len) >= MIN_MODULES {
return Ok((shallow, 1));
}
let deeper = section(
engine,
"module_map",
&json!({"top_n": MAX_MODULES, "depth": 2}),
)?;
if deeper["modules"].as_array().map_or(0, Vec::len)
> shallow["modules"].as_array().map_or(0, Vec::len)
{
return Ok((deeper, 2));
}
Ok((shallow, 1))
}
fn section(engine: &mut Weavatrix, name: &str, arguments: &Value) -> Result<Value, String> {
if !operations::catalog().iter().any(|tool| tool.name == name) {
return Ok(json!({
"present": false,
"reason": format!("{name} is not compiled into this build")
}));
}
operations::call(engine, name, arguments.clone())
}
fn module_graph(
engine: &Weavatrix,
module_map: &Value,
architecture: &Value,
depth: usize,
) -> (Vec<Module>, Vec<layout::Link>) {
let state = engine.state();
let files = state
.graph()
.nodes()
.iter()
.filter(|node| node.kind == NodeKind::File)
.map(|node| node.label.as_str())
.collect::<BTreeSet<_>>();
let violations = violations_by_module(architecture, &files, depth);
let rows = module_map["modules"]
.as_array()
.cloned()
.unwrap_or_default();
let mut modules = Vec::new();
let mut index = BTreeMap::new();
for row in rows.iter().take(MAX_MODULES) {
let Some(path) = row["path"].as_str() else {
continue;
};
index.insert(path.to_owned(), modules.len());
modules.push(Module {
path: path.to_owned(),
files: row["files"].as_u64().unwrap_or(0),
symbols: row["symbols"].as_u64().unwrap_or(0),
violations: violations.get(path).copied().unwrap_or(0),
position: (0.0, 0.0),
drawn: false,
});
}
let mut weights = BTreeMap::<(usize, usize), u64>::new();
for edge in state.graph().edges() {
if !matches!(edge.kind, EdgeKind::Imports | EdgeKind::ReExports) {
continue;
}
let (Some(from), Some(to)) = (
module_index(state, edge.source.as_str(), &index, depth),
module_index(state, edge.target.as_str(), &index, depth),
) else {
continue;
};
if from != to {
*weights.entry((from.min(to), from.max(to))).or_default() += 1;
}
}
let links = weights
.into_iter()
.map(|((from, to), weight)| (from, to, weight))
.collect::<Vec<_>>();
lay_out(&mut modules, &links);
(modules, links)
}
fn lay_out(modules: &mut [Module], links: &[layout::Link]) {
let mut coupled = BTreeSet::new();
for (from, to, _) in links {
coupled.insert(*from);
coupled.insert(*to);
}
let mut slots = BTreeMap::new();
for (position, module) in modules.iter_mut().enumerate() {
module.drawn = module.symbols > 0 || coupled.contains(&position);
if module.drawn {
slots.insert(position, slots.len());
}
}
let drawn_links = links
.iter()
.filter_map(|(from, to, weight)| Some((*slots.get(from)?, *slots.get(to)?, *weight)))
.collect::<Vec<_>>();
let placed = layout::place(slots.len(), &drawn_links);
for (position, slot) in slots {
if let (Some(module), Some(point)) = (modules.get_mut(position), placed.get(slot)) {
module.position = *point;
}
}
}
fn module_index(
state: &RepositoryState,
id: &str,
index: &BTreeMap<String, usize>,
depth: usize,
) -> Option<usize> {
let node = state.graph().node(id)?;
let path = operations::node_path(node)?;
index.get(&module_of(path, depth)).copied()
}
fn module_of(path: &str, depth: usize) -> String {
let segments = path.split('/').collect::<Vec<_>>();
let directories = segments.len().saturating_sub(1);
if directories == 0 {
return "(root)".to_owned();
}
segments[..directories.min(depth)].join("/")
}
fn violations_by_module(
architecture: &Value,
files: &BTreeSet<&str>,
depth: usize,
) -> BTreeMap<String, u64> {
let mut counts = BTreeMap::new();
let Some(violations) = architecture["new"].as_array() else {
return counts;
};
for violation in violations {
let mut named = BTreeSet::new();
collect_files(violation, files, &mut named);
for path in named {
*counts.entry(module_of(&path, depth)).or_default() += 1;
}
}
counts
}
fn collect_files(value: &Value, files: &BTreeSet<&str>, found: &mut BTreeSet<String>) {
if let Some(text) = value.as_str() {
if files.contains(text) {
found.insert(text.to_owned());
}
return;
}
if let Some(items) = value.as_array() {
for item in items {
collect_files(item, files, found);
}
return;
}
if let Some(object) = value.as_object() {
for item in object.values() {
collect_files(item, files, found);
}
}
}