use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use seshat_core::{
BranchId, Edge, EdgeId, EdgeType, KnowledgeNature, KnowledgeNode, KnowledgeWeight, Language,
NodeId, ProjectFile,
};
#[derive(Debug, Clone)]
pub struct ModuleInfo {
pub path: PathBuf,
pub files: Vec<PathBuf>,
pub languages: BTreeSet<String>,
}
#[derive(Debug)]
pub struct ModuleGraph {
pub nodes: Vec<KnowledgeNode>,
pub edges: Vec<Edge>,
pub modules: HashMap<PathBuf, ModuleInfo>,
path_to_node_id: HashMap<PathBuf, NodeId>,
node_id_to_path: HashMap<NodeId, PathBuf>,
}
impl ModuleGraph {
pub fn dependencies_of(&self, module_path: &Path) -> Vec<&PathBuf> {
let Some(&source_node_id) = self.path_to_node_id.get(module_path) else {
return Vec::new();
};
self.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn && e.source_id == source_node_id)
.filter_map(|e| self.node_id_to_path.get(&e.target_id))
.collect()
}
pub fn dependents_of(&self, module_path: &Path) -> Vec<&PathBuf> {
let Some(&target_node_id) = self.path_to_node_id.get(module_path) else {
return Vec::new();
};
self.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn && e.target_id == target_node_id)
.filter_map(|e| self.node_id_to_path.get(&e.source_id))
.collect()
}
}
pub fn build_module_graph(
project_root: &Path,
parsed_files: &[ProjectFile],
branch_id: &BranchId,
) -> ModuleGraph {
let mut dir_files: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut dir_languages: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
for pf in parsed_files {
let rel_path = make_relative(&pf.path, project_root);
let dir = rel_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
dir_files
.entry(dir.clone())
.or_default()
.push(rel_path.clone());
dir_languages
.entry(dir)
.or_default()
.insert(pf.language.as_str().to_owned());
}
let file_map: HashMap<PathBuf, &ProjectFile> = parsed_files
.iter()
.map(|pf| (make_relative(&pf.path, project_root), pf))
.collect();
let module_paths: Vec<PathBuf> = dir_files.keys().cloned().collect();
let path_to_node_id: HashMap<&PathBuf, NodeId> = module_paths
.iter()
.enumerate()
.map(|(i, p)| (p, NodeId((i + 1) as i64)))
.collect();
let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
for (dir, files) in &dir_files {
modules.insert(
dir.clone(),
ModuleInfo {
path: dir.clone(),
files: files.clone(),
languages: dir_languages.get(dir).cloned().unwrap_or_default(),
},
);
}
let nodes: Vec<KnowledgeNode> = module_paths
.iter()
.map(|dir| {
let info = &modules[dir];
let node_id = path_to_node_id[dir];
let purpose = derive_module_purpose(&info.files, &file_map);
let description = format!(
"Module '{}' containing {} file(s) [{}]",
if dir.as_os_str().is_empty() {
"(root)"
} else {
dir.to_str().unwrap_or("(non-utf8)")
},
info.files.len(),
info.languages
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
let mut ext = serde_json::json!({
"source": "module_structure",
"module_path": dir.to_str().unwrap_or(""),
"file_count": info.files.len(),
"languages": info.languages.iter().cloned().collect::<Vec<_>>(),
"files": info.files.iter().map(|f| f.to_str().unwrap_or("").to_owned()).collect::<Vec<_>>(),
});
if let Some(ref p) = purpose {
ext["purpose"] = serde_json::Value::String(p.clone());
}
let ext_data = ext;
KnowledgeNode {
id: node_id,
branch_id: branch_id.clone(),
nature: KnowledgeNature::Fact,
weight: KnowledgeWeight::Info,
confidence: 1.0,
adoption_count: 1,
total_count: 1,
description,
ext_data: Some(ext_data),
}
})
.collect();
let import_target_map = build_import_target_map(project_root, parsed_files);
let mut edge_id_counter: i64 = 1;
let mut depends_on_set: BTreeSet<(PathBuf, PathBuf)> = BTreeSet::new();
for pf in parsed_files {
let rel = make_relative(&pf.path, project_root);
let source_dir = rel.parent().map(|p| p.to_path_buf()).unwrap_or_default();
for import in &pf.imports {
if let Some(target_dir) = resolve_import_to_module(
&import.module,
&source_dir,
&import_target_map,
&pf.language,
) {
if target_dir != source_dir {
depends_on_set.insert((source_dir.clone(), target_dir));
}
}
}
}
let mut edges: Vec<Edge> = Vec::new();
for (source_dir, target_dir) in &depends_on_set {
if let (Some(&source_id), Some(&target_id)) = (
path_to_node_id.get(source_dir),
path_to_node_id.get(target_dir),
) {
edges.push(Edge {
id: EdgeId(edge_id_counter),
source_id,
target_id,
edge_type: EdgeType::DependsOn,
branch_id: branch_id.clone(),
weight: 1.0,
metadata: Some(serde_json::json!({
"source_module": source_dir.to_str().unwrap_or(""),
"target_module": target_dir.to_str().unwrap_or(""),
})),
});
edge_id_counter += 1;
}
}
for dir in &module_paths {
if dir.as_os_str().is_empty() {
continue; }
if let Some(parent) = dir.parent() {
let parent_path = parent.to_path_buf();
if let (Some(&child_id), Some(&parent_id)) =
(path_to_node_id.get(dir), path_to_node_id.get(&parent_path))
{
edges.push(Edge {
id: EdgeId(edge_id_counter),
source_id: child_id,
target_id: parent_id,
edge_type: EdgeType::PartOf,
branch_id: branch_id.clone(),
weight: 1.0,
metadata: Some(serde_json::json!({
"child_module": dir.to_str().unwrap_or(""),
"parent_module": parent_path.to_str().unwrap_or(""),
})),
});
edge_id_counter += 1;
}
}
}
let path_to_node_id_owned: HashMap<PathBuf, NodeId> = path_to_node_id
.iter()
.map(|(p, &id)| ((*p).clone(), id))
.collect();
let node_id_to_path: HashMap<NodeId, PathBuf> = path_to_node_id_owned
.iter()
.map(|(p, &id)| (id, p.clone()))
.collect();
ModuleGraph {
nodes,
edges,
modules,
path_to_node_id: path_to_node_id_owned,
node_id_to_path,
}
}
fn build_import_target_map(
project_root: &Path,
parsed_files: &[ProjectFile],
) -> HashMap<String, PathBuf> {
let mut map: HashMap<String, PathBuf> = HashMap::new();
let mut module_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for pf in parsed_files {
let rel = make_relative(&pf.path, project_root);
let dir = rel.parent().map(|p| p.to_path_buf()).unwrap_or_default();
module_dirs.insert(dir.clone());
if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
let no_ext = if dir.as_os_str().is_empty() {
stem.to_owned()
} else {
format!("{}/{stem}", dir.display())
};
map.entry(no_ext).or_insert_with(|| dir.clone());
}
map.entry(rel.to_string_lossy().to_string())
.or_insert_with(|| dir.clone());
if pf.language == Language::Python {
let dotted = rel
.with_extension("")
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(".");
map.entry(dotted.clone()).or_insert_with(|| dir.clone());
if !dir.as_os_str().is_empty() {
let dir_dotted = dir
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(".");
map.entry(dir_dotted).or_insert_with(|| dir.clone());
}
}
if pf.language == Language::Rust {
let rust_path = rel
.with_extension("")
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("::");
map.entry(rust_path).or_insert_with(|| dir.clone());
}
for export in &pf.exports {
if !export.name.is_empty() {
let qualified = if dir.as_os_str().is_empty() {
export.name.clone()
} else {
format!("{}/{}", dir.display(), export.name)
};
map.entry(qualified).or_insert_with(|| dir.clone());
}
}
}
for dir in &module_dirs {
if dir.as_os_str().is_empty() {
continue;
}
let dir_str = dir.to_string_lossy().to_string();
map.entry(dir_str).or_insert_with(|| dir.clone());
let rust_dir_path = dir
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("::");
map.entry(rust_dir_path).or_insert_with(|| dir.clone());
let dotted_dir = dir
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(".");
map.entry(dotted_dir).or_insert_with(|| dir.clone());
if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
map.entry(name.to_owned()).or_insert_with(|| dir.clone());
}
}
map
}
fn resolve_import_to_module(
import_module: &str,
source_dir: &Path,
target_map: &HashMap<String, PathBuf>,
language: &Language,
) -> Option<PathBuf> {
if let Some(dir) = target_map.get(import_module) {
return Some(dir.clone());
}
if import_module.starts_with('.') {
let cleaned = import_module
.trim_start_matches("./")
.trim_start_matches("../");
let resolved = if import_module.starts_with("../") {
source_dir
.parent()
.map(|p| p.join(cleaned))
.unwrap_or_else(|| PathBuf::from(cleaned))
} else if import_module.starts_with("./") {
source_dir.join(cleaned)
} else {
return Some(source_dir.to_path_buf());
};
let resolved_str = resolved.to_string_lossy().to_string();
if let Some(dir) = target_map.get(&resolved_str) {
return Some(dir.clone());
}
if target_map.values().any(|d| *d == resolved) {
return Some(resolved);
}
}
if *language == Language::Python && import_module.contains('.') {
if let Some(dir) = target_map.get(import_module) {
return Some(dir.clone());
}
let base = import_module.split('.').next().unwrap_or(import_module);
if let Some(dir) = target_map.get(base) {
return Some(dir.clone());
}
}
if *language == Language::Rust {
if let Some(rest) = import_module.strip_prefix("crate::") {
let as_path = rest.replace("::", "/");
if let Some(dir) = target_map.get(&as_path) {
return Some(dir.clone());
}
let src_path = format!("src/{as_path}");
if let Some(dir) = target_map.get(&src_path) {
return Some(dir.clone());
}
}
if let Some(rest) = import_module.strip_prefix("super::") {
if let Some(parent) = source_dir.parent() {
let as_path = rest.replace("::", "/");
let resolved = parent.join(&as_path);
let resolved_str = resolved.to_string_lossy().to_string();
if let Some(dir) = target_map.get(&resolved_str) {
return Some(dir.clone());
}
}
}
if let Some(rest) = import_module.strip_prefix("self::") {
let as_path = rest.replace("::", "/");
let resolved = source_dir.join(&as_path);
let resolved_str = resolved.to_string_lossy().to_string();
if let Some(dir) = target_map.get(&resolved_str) {
return Some(dir.clone());
}
}
}
if matches!(language, Language::JavaScript | Language::TypeScript) {
if import_module.starts_with('.') {
let cleaned = import_module
.trim_start_matches("./")
.trim_start_matches("../");
let base = if import_module.starts_with("../") {
source_dir
.parent()
.map(|p| p.join(cleaned))
.unwrap_or_else(|| PathBuf::from(cleaned))
} else {
source_dir.join(cleaned)
};
let base_str = base.to_string_lossy().to_string();
let index_path = format!("{base_str}/index");
if let Some(dir) = target_map.get(&index_path) {
return Some(dir.clone());
}
}
}
None
}
fn make_relative(path: &Path, root: &Path) -> PathBuf {
path.strip_prefix(root)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| path.to_path_buf())
}
const MIN_DOC_LEN: usize = 8;
fn is_noise_file_doc(s: &str) -> bool {
let s = s.trim();
s.starts_with("@ts-") || s.starts_with("@type") || s.starts_with("@jest-")
|| s.starts_with("@flow")
|| s.starts_with("@noinspection")
|| s.starts_with("eslint-disable")
|| s.starts_with("// eslint-disable")
|| s.starts_with("/* eslint-disable")
|| s.starts_with("noqa")
|| s.contains("type: ignore")
|| s.contains("type:ignore")
|| s.starts_with("#!") || s.len() < MIN_DOC_LEN }
fn clean_doc_text(s: &str, max_lines: usize) -> String {
s.lines()
.map(|line| line.trim_start_matches('#').trim())
.filter(|line| !line.is_empty())
.take(max_lines)
.collect::<Vec<_>>()
.join("\n")
}
fn derive_module_purpose(
files: &[PathBuf],
file_map: &HashMap<PathBuf, &ProjectFile>,
) -> Option<String> {
const ENTRY_POINT_NAMES: &[&str] = &[
"lib.rs",
"mod.rs",
"main.rs",
"__init__.py",
"index.ts",
"index.js",
"index.mjs",
];
const ENTRY_POINT_MAX_LINES: usize = 5;
const MAX_DOCS: usize = 10;
const MAX_LINES_PER_DOC: usize = 3;
const MAX_SYMBOLS: usize = 8;
for file_path in files {
let file_name = file_path.file_name().and_then(|f| f.to_str()).unwrap_or("");
if ENTRY_POINT_NAMES.contains(&file_name) {
if let Some(pf) = file_map.get(file_path) {
if let Some(ref doc) = pf.file_doc {
let raw = doc.trim();
if !raw.is_empty() && !is_noise_file_doc(raw) {
let cleaned = clean_doc_text(raw, ENTRY_POINT_MAX_LINES);
if !cleaned.is_empty() {
return Some(cleaned);
}
}
}
}
}
}
let file_docs: Vec<String> = files
.iter()
.filter_map(|fp| {
let pf = file_map.get(fp)?;
let raw = pf.file_doc.as_deref()?.trim();
if raw.is_empty() || is_noise_file_doc(raw) {
return None;
}
let cleaned = clean_doc_text(raw, MAX_LINES_PER_DOC);
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
})
.take(MAX_DOCS)
.collect();
if !file_docs.is_empty() {
return Some(file_docs.join(" | "));
}
let mut seen = std::collections::HashSet::new();
let mut symbols: Vec<String> = Vec::new();
for file_path in files {
if let Some(pf) = file_map.get(file_path) {
for f in &pf.functions {
if f.is_public && seen.insert(f.name.clone()) {
symbols.push(f.name.clone());
}
}
for t in &pf.types {
if t.is_public && seen.insert(t.name.clone()) {
symbols.push(t.name.clone());
}
}
}
}
if symbols.is_empty() {
return None;
}
let total = symbols.len();
let shown = symbols.into_iter().take(MAX_SYMBOLS).collect::<Vec<_>>();
let mut result = shown.join(", ");
if total > MAX_SYMBOLS {
result.push_str(&format!(" +{} more", total - MAX_SYMBOLS));
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
use seshat_core::{
Export, Import, JavaScriptIR, Language, LanguageIR, PythonIR, RustIR, TypeScriptIR,
};
use std::path::PathBuf;
fn make_project_file(
path: &str,
language: Language,
imports: Vec<Import>,
exports: Vec<Export>,
) -> ProjectFile {
ProjectFile {
path: PathBuf::from(path),
language,
content_hash: "test_hash".to_owned(),
imports,
exports,
functions: Vec::new(),
types: Vec::new(),
dependencies_used: Vec::new(),
language_ir: match language {
Language::Rust => LanguageIR::Rust(RustIR::default()),
Language::TypeScript => LanguageIR::TypeScript(TypeScriptIR::default()),
Language::JavaScript => LanguageIR::JavaScript(JavaScriptIR::default()),
Language::Python => LanguageIR::Python(PythonIR::default()),
},
file_doc: None,
}
}
fn import(module: &str) -> Import {
Import {
module: module.to_owned(),
names: Vec::new(),
is_type_only: false,
line: 1,
}
}
fn import_with_names(module: &str, names: &[&str]) -> Import {
Import {
module: module.to_owned(),
names: names.iter().map(|n| n.to_string()).collect(),
is_type_only: false,
line: 1,
}
}
fn export(name: &str) -> Export {
Export {
name: name.to_owned(),
is_default: false,
is_type_only: false,
line: 1,
end_line: 1,
}
}
#[test]
fn detects_modules_from_directories() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file("/project/src/lib.rs", Language::Rust, vec![], vec![]),
make_project_file(
"/project/tests/test_main.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert_eq!(graph.modules.len(), 2);
assert!(graph.modules.contains_key(&PathBuf::from("src")));
assert!(graph.modules.contains_key(&PathBuf::from("tests")));
}
#[test]
fn root_directory_detected_as_module() {
let root = Path::new("/project");
let files = vec![make_project_file(
"/project/main.py",
Language::Python,
vec![],
vec![],
)];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert_eq!(graph.modules.len(), 1);
assert!(graph.modules.contains_key(&PathBuf::from("")));
}
#[test]
fn nested_modules_detected() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file(
"/project/src/handlers/api.rs",
Language::Rust,
vec![],
vec![],
),
make_project_file(
"/project/src/handlers/web.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert_eq!(graph.modules.len(), 2);
assert!(graph.modules.contains_key(&PathBuf::from("src")));
assert!(graph.modules.contains_key(&PathBuf::from("src/handlers")));
}
#[test]
fn module_tracks_languages() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/index.ts",
Language::TypeScript,
vec![],
vec![],
),
make_project_file(
"/project/src/utils.js",
Language::JavaScript,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let src = &graph.modules[&PathBuf::from("src")];
assert!(src.languages.contains("typescript"));
assert!(src.languages.contains("javascript"));
}
#[test]
fn module_tracks_files() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file("/project/src/config.rs", Language::Rust, vec![], vec![]),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let src = &graph.modules[&PathBuf::from("src")];
assert_eq!(src.files.len(), 2);
}
#[test]
fn creates_fact_nodes_for_modules() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file("/project/tests/test.rs", Language::Rust, vec![], vec![]),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert_eq!(graph.nodes.len(), 2);
for node in &graph.nodes {
assert_eq!(node.nature, KnowledgeNature::Fact);
assert_eq!(node.weight, KnowledgeWeight::Info);
assert_eq!(node.confidence, 1.0);
assert_eq!(node.branch_id, BranchId::from("main"));
assert!(node.ext_data.is_some());
}
}
#[test]
fn node_ext_data_contains_module_info() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file("/project/src/lib.rs", Language::Rust, vec![], vec![]),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let node = graph
.nodes
.iter()
.find(|n| n.description.contains("'src'"))
.expect("should have src module node");
let ext = node.ext_data.as_ref().unwrap();
assert_eq!(ext["source"], "module_structure");
assert_eq!(ext["module_path"], "src");
assert_eq!(ext["file_count"], 2);
}
#[test]
fn creates_depends_on_edges_for_relative_imports_ts() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/index.ts",
Language::TypeScript,
vec![import("./utils")],
vec![],
),
make_project_file(
"/project/src/utils.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(
depends_on.len(),
0,
"Same-module imports should not produce DependsOn edges"
);
}
#[test]
fn creates_depends_on_edges_cross_directory_ts() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/pages/home.ts",
Language::TypeScript,
vec![import("../utils/format")],
vec![],
),
make_project_file(
"/project/src/utils/format.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
assert_eq!(depends_on[0].edge_type, EdgeType::DependsOn);
}
#[test]
fn creates_depends_on_edges_rust_crate_imports() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/main.rs",
Language::Rust,
vec![import_with_names("crate::config", &["Config"])],
vec![],
),
make_project_file(
"/project/src/config.rs",
Language::Rust,
vec![],
vec![export("Config")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(
depends_on.len(),
0,
"Same-module crate:: imports should not produce edges"
);
}
#[test]
fn creates_depends_on_edges_rust_cross_module() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/handlers/api.rs",
Language::Rust,
vec![import_with_names("crate::models", &["User"])],
vec![],
),
make_project_file(
"/project/src/models/user.rs",
Language::Rust,
vec![],
vec![export("User")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
}
#[test]
fn creates_depends_on_edges_python_dotted_imports() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/mypackage/services.py",
Language::Python,
vec![import("mypackage.models")],
vec![],
),
make_project_file(
"/project/mypackage/models.py",
Language::Python,
vec![],
vec![export("User")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 0);
}
#[test]
fn creates_depends_on_edges_python_cross_directory() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/app/views.py",
Language::Python,
vec![import("models.user")],
vec![],
),
make_project_file(
"/project/models/user.py",
Language::Python,
vec![],
vec![export("User")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
}
#[test]
fn no_duplicate_depends_on_edges() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/pages/home.ts",
Language::TypeScript,
vec![
import("../utils/format"),
import("../utils/validate"), ],
vec![],
),
make_project_file(
"/project/src/utils/format.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
make_project_file(
"/project/src/utils/validate.ts",
Language::TypeScript,
vec![],
vec![export("isValid")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
}
#[test]
fn self_imports_not_edges() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/a.ts",
Language::TypeScript,
vec![import("./b")],
vec![],
),
make_project_file(
"/project/src/b.ts",
Language::TypeScript,
vec![],
vec![export("B")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(
depends_on.len(),
0,
"Same-directory imports should not produce edges"
);
}
#[test]
fn creates_part_of_edges_for_nested_modules() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file(
"/project/src/handlers/api.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let part_of: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::PartOf)
.collect();
assert_eq!(part_of.len(), 1);
}
#[test]
fn no_part_of_for_root_module() {
let root = Path::new("/project");
let files = vec![make_project_file(
"/project/main.py",
Language::Python,
vec![],
vec![],
)];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let part_of: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::PartOf)
.collect();
assert_eq!(part_of.len(), 0);
}
#[test]
fn part_of_only_when_parent_is_module() {
let root = Path::new("/project");
let files = vec![make_project_file(
"/project/src/deep/nested/file.rs",
Language::Rust,
vec![],
vec![],
)];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let part_of: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::PartOf)
.collect();
assert_eq!(part_of.len(), 0);
}
#[test]
fn deep_hierarchy_part_of_chain() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file(
"/project/src/api/handler.rs",
Language::Rust,
vec![],
vec![],
),
make_project_file(
"/project/src/api/v2/handler.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let part_of: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::PartOf)
.collect();
assert_eq!(part_of.len(), 2);
}
#[test]
fn query_dependencies_of() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/pages/home.ts",
Language::TypeScript,
vec![import("../utils/format")],
vec![],
),
make_project_file(
"/project/src/utils/format.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let deps = graph.dependencies_of(Path::new("src/pages"));
assert_eq!(deps.len(), 1);
assert_eq!(*deps[0], PathBuf::from("src/utils"));
}
#[test]
fn query_dependents_of() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/pages/home.ts",
Language::TypeScript,
vec![import("../utils/format")],
vec![],
),
make_project_file(
"/project/src/utils/format.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let dependents = graph.dependents_of(Path::new("src/utils"));
assert_eq!(dependents.len(), 1);
assert_eq!(*dependents[0], PathBuf::from("src/pages"));
}
#[test]
fn query_nonexistent_module_returns_empty() {
let root = Path::new("/project");
let files = vec![make_project_file(
"/project/src/main.rs",
Language::Rust,
vec![],
vec![],
)];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert!(graph.dependencies_of(Path::new("nonexistent")).is_empty());
assert!(graph.dependents_of(Path::new("nonexistent")).is_empty());
}
#[test]
fn empty_files_produces_empty_graph() {
let root = Path::new("/project");
let graph = build_module_graph(root, &[], &BranchId::from("main"));
assert!(graph.nodes.is_empty());
assert!(graph.edges.is_empty());
assert!(graph.modules.is_empty());
}
#[test]
fn mixed_language_project() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/frontend/src/App.tsx",
Language::TypeScript,
vec![import("../shared/types")],
vec![],
),
make_project_file(
"/project/frontend/shared/types.ts",
Language::TypeScript,
vec![],
vec![export("AppConfig")],
),
make_project_file(
"/project/backend/src/main.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
assert_eq!(graph.modules.len(), 3);
assert_eq!(graph.nodes.len(), 3);
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
}
#[test]
fn js_index_barrel_import() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/app.ts",
Language::TypeScript,
vec![import("./components")], vec![],
),
make_project_file(
"/project/src/components/index.ts",
Language::TypeScript,
vec![],
vec![export("Button"), export("Input")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(depends_on.len(), 1);
}
#[test]
fn depends_on_edge_has_metadata() {
let root = Path::new("/project");
let files = vec![
make_project_file(
"/project/src/pages/home.ts",
Language::TypeScript,
vec![import("../utils/format")],
vec![],
),
make_project_file(
"/project/src/utils/format.ts",
Language::TypeScript,
vec![],
vec![export("formatDate")],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let depends_on = graph
.edges
.iter()
.find(|e| e.edge_type == EdgeType::DependsOn)
.expect("should have DependsOn edge");
let metadata = depends_on.metadata.as_ref().expect("should have metadata");
assert!(metadata.get("source_module").is_some());
assert!(metadata.get("target_module").is_some());
}
#[test]
fn part_of_edge_has_metadata() {
let root = Path::new("/project");
let files = vec![
make_project_file("/project/src/main.rs", Language::Rust, vec![], vec![]),
make_project_file(
"/project/src/handlers/api.rs",
Language::Rust,
vec![],
vec![],
),
];
let graph = build_module_graph(root, &files, &BranchId::from("main"));
let part_of = graph
.edges
.iter()
.find(|e| e.edge_type == EdgeType::PartOf)
.expect("should have PartOf edge");
let metadata = part_of.metadata.as_ref().expect("should have metadata");
assert!(metadata.get("child_module").is_some());
assert!(metadata.get("parent_module").is_some());
}
fn make_file_with_doc(path: &str, file_doc: Option<&str>) -> ProjectFile {
let mut pf = make_project_file(path, Language::Rust, vec![], vec![]);
pf.file_doc = file_doc.map(str::to_owned);
pf
}
fn make_file_with_pub_fn(path: &str, fn_name: &str) -> ProjectFile {
let pf_base = make_project_file(path, Language::Rust, vec![], vec![]);
ProjectFile {
functions: vec![seshat_core::Function {
name: fn_name.to_owned(),
is_public: true,
is_async: false,
line: 1,
end_line: 5,
parameters: vec![],
doc_comment: None,
}],
..pf_base
}
}
#[test]
fn purpose_from_entry_point_file_doc() {
let lib_rs = make_file_with_doc("/project/src/lib.rs", Some("Authentication module."));
let other = make_file_with_doc("/project/src/handler.rs", Some("Handles requests."));
let file_map: HashMap<PathBuf, &ProjectFile> = [
(PathBuf::from("src/lib.rs"), &lib_rs),
(PathBuf::from("src/handler.rs"), &other),
]
.into_iter()
.collect();
let files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/handler.rs")];
let purpose = derive_module_purpose(&files, &file_map);
assert_eq!(purpose.as_deref(), Some("Authentication module."));
}
#[test]
fn purpose_falls_back_to_file_docs_when_no_entry_point() {
let handler = make_file_with_doc("/project/src/handler.rs", Some("Handles HTTP."));
let service = make_file_with_doc("/project/src/service.rs", Some("Business logic."));
let file_map: HashMap<PathBuf, &ProjectFile> = [
(PathBuf::from("src/handler.rs"), &handler),
(PathBuf::from("src/service.rs"), &service),
]
.into_iter()
.collect();
let files = vec![
PathBuf::from("src/handler.rs"),
PathBuf::from("src/service.rs"),
];
let purpose = derive_module_purpose(&files, &file_map);
let p = purpose.unwrap();
assert!(p.contains("Handles HTTP."), "got: {p}");
assert!(p.contains("Business logic."), "got: {p}");
}
#[test]
fn purpose_falls_back_to_symbols_when_no_docs() {
let pf = make_file_with_pub_fn("/project/src/handler.rs", "handle_request");
let file_map: HashMap<PathBuf, &ProjectFile> = [(PathBuf::from("src/handler.rs"), &pf)]
.into_iter()
.collect();
let files = vec![PathBuf::from("src/handler.rs")];
let purpose = derive_module_purpose(&files, &file_map);
let p = purpose.unwrap();
assert!(p.contains("handle_request"), "got: {p}");
}
#[test]
fn purpose_is_none_when_no_docs_no_symbols() {
let pf = make_file_with_doc("/project/src/empty.rs", None);
let file_map: HashMap<PathBuf, &ProjectFile> =
[(PathBuf::from("src/empty.rs"), &pf)].into_iter().collect();
let files = vec![PathBuf::from("src/empty.rs")];
let purpose = derive_module_purpose(&files, &file_map);
assert!(purpose.is_none());
}
#[test]
fn noise_filter_rejects_ts_nocheck() {
assert!(is_noise_file_doc("@ts-nocheck"));
assert!(is_noise_file_doc("@ts-ignore"));
}
#[test]
fn noise_filter_rejects_type_annotation() {
assert!(is_noise_file_doc("@type {import('next').NextConfig}"));
}
#[test]
fn noise_filter_rejects_eslint_disable() {
assert!(is_noise_file_doc("eslint-disable no-console"));
assert!(is_noise_file_doc("// eslint-disable-next-line"));
assert!(is_noise_file_doc(
"eslint-disable @typescript-eslint/no-explicit-any"
));
}
#[test]
fn noise_filter_rejects_python_noqa() {
assert!(is_noise_file_doc("noqa: E501"));
assert!(is_noise_file_doc("noqa"));
}
#[test]
fn noise_filter_rejects_type_ignore() {
assert!(is_noise_file_doc("type: ignore"));
assert!(is_noise_file_doc("type:ignore"));
}
#[test]
fn noise_filter_rejects_short_strings() {
assert!(is_noise_file_doc("ok"));
assert!(is_noise_file_doc(" hi "));
}
#[test]
fn noise_filter_accepts_real_doc() {
assert!(!is_noise_file_doc(
"Handles authentication and session management."
));
assert!(!is_noise_file_doc(
"# Auth Module\n\nProvides JWT-based login."
));
}
#[test]
fn noise_docs_excluded_from_purpose() {
let index_ts = make_file_with_doc("/project/src/index.ts", Some("@ts-nocheck\n// barrel"));
let service =
make_file_with_doc("/project/src/service.ts", Some("Handles user operations."));
let file_map: HashMap<PathBuf, &ProjectFile> = [
(PathBuf::from("src/index.ts"), &index_ts),
(PathBuf::from("src/service.ts"), &service),
]
.into_iter()
.collect();
let files = vec![
PathBuf::from("src/index.ts"),
PathBuf::from("src/service.ts"),
];
let purpose = derive_module_purpose(&files, &file_map);
let p = purpose.as_deref().unwrap_or("");
assert!(!p.contains("@ts-nocheck"), "noise must be filtered: {p}");
assert!(
p.contains("Handles user operations."),
"real doc missing: {p}"
);
}
#[test]
fn markdown_headings_stripped_from_purpose() {
let lib_rs = make_file_with_doc(
"/project/src/lib.rs",
Some("# Auth Module\n\nProvides JWT-based login."),
);
let file_map: HashMap<PathBuf, &ProjectFile> = [(PathBuf::from("src/lib.rs"), &lib_rs)]
.into_iter()
.collect();
let files = vec![PathBuf::from("src/lib.rs")];
let purpose = derive_module_purpose(&files, &file_map);
let p = purpose.as_deref().unwrap_or("");
assert!(
!p.starts_with('#'),
"markdown heading must be stripped: {p}"
);
assert!(p.contains("Auth Module"), "heading text should remain: {p}");
assert!(
p.contains("Provides JWT-based login."),
"body must be kept: {p}"
);
}
#[test]
fn symbols_are_deduplicated() {
let f1 = {
let mut pf = make_file_with_pub_fn("/project/src/a.rs", "new");
pf.functions.push(seshat_core::Function {
name: "run".to_owned(),
is_public: true,
is_async: false,
line: 10,
end_line: 20,
parameters: vec![],
doc_comment: None,
});
pf
};
let f2 = make_file_with_pub_fn("/project/src/b.rs", "new");
let file_map: HashMap<PathBuf, &ProjectFile> = [
(PathBuf::from("src/a.rs"), &f1),
(PathBuf::from("src/b.rs"), &f2),
]
.into_iter()
.collect();
let files = vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")];
let purpose = derive_module_purpose(&files, &file_map);
let p = purpose.unwrap();
assert_eq!(
p.matches("new").count(),
1,
"duplicate symbol in purpose: {p}"
);
assert!(p.contains("run"), "unique symbol missing: {p}");
}
#[test]
fn file_doc_truncated_to_max_lines() {
let doc = "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8";
let lib_rs = make_file_with_doc("/project/src/lib.rs", Some(doc));
let file_map: HashMap<PathBuf, &ProjectFile> = [(PathBuf::from("src/lib.rs"), &lib_rs)]
.into_iter()
.collect();
let files = vec![PathBuf::from("src/lib.rs")];
let purpose = derive_module_purpose(&files, &file_map).unwrap();
let line_count = purpose.lines().count();
assert!(
line_count <= 5,
"entry-point doc should be ≤5 lines, got {line_count}: {purpose}"
);
assert!(
!purpose.contains("Line6"),
"line 6 must be truncated: {purpose}"
);
}
}