# notgraph Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a `notgraph` binary crate that analyses the notfiles workspace and emits a Markdown summary, JSON report, and interactive HTML file showing crate/module dependency graphs, hotspots, and symbol inventory — with CI enforcement of zero module cycles. **Architecture:** Three pipeline stages: Collect (cargo_metadata + syn/walkdir) → Analyze (fan counts, Kahn's cycle detection, hotspot ranking) → Emit (report.md, report.json, report.html). Five focused modules: `crate_graph`, `module_graph`, `symbols`, `analysis`, `emit`. Binary-only crate (with lib target for integration tests); no other workspace crate depends on it. **Tech Stack:** Rust 2024 edition, cargo_metadata 0.18, syn 2 (full+visit features), walkdir 2, serde/serde_json 1, anyhow 1, clap 4 --- ## File Map | File | Purpose | | --------------------------------------------------- | ----------------------------------------------------------------------------- | | `crates/notgraph/Cargo.toml` | Crate manifest with all deps | | `crates/notgraph/src/main.rs` | CLI (clap), wires collect->analyze->emit, exits 1 on cycles+flag | | `crates/notgraph/src/lib.rs` | Re-exports all modules for integration test access | | `crates/notgraph/src/types.rs` | All shared data types: CrateGraph, ModuleGraph, SymbolTable, GraphStats, etc. | | `crates/notgraph/src/crate_graph.rs` | Builds CrateGraph from cargo metadata | | `crates/notgraph/src/module_graph.rs` | Walks src/ + parses mod declarations -> ModuleGraph per crate | | `crates/notgraph/src/symbols.rs` | Parses .rs files -> SymbolTable per crate | | `crates/notgraph/src/analysis.rs` | Fan-in/fan-out, Kahn's cycle detection, hotspot ranking -> GraphStats | | `crates/notgraph/src/emit.rs` | Writes report.md, report.json, report.html | | `crates/notgraph/tests/integration.rs` | Integration tests using fixture crates | | `crates/notgraph/tests/fixtures/clean/src/lib.rs` | Fixture: clean module tree (no cycles) | | `crates/notgraph/tests/fixtures/clean/src/alpha.rs` | Fixture module depended-on by beta | | `crates/notgraph/tests/fixtures/clean/src/beta.rs` | Fixture module that uses alpha (fan-out=1) | | `crates/notgraph/tests/fixtures/cyclic/src/lib.rs` | Fixture: two modules that declare each other | | `crates/notgraph/tests/fixtures/cyclic/src/foo.rs` | Fixture cycle participant | | `crates/notgraph/tests/fixtures/cyclic/src/bar.rs` | Fixture cycle participant | | `crates/notgraph/tests/fixtures/symbols/src/lib.rs` | Fixture: known public symbols for assertion | --- ## Task 1: Scaffold the crate **Files:** - Create: `crates/notgraph/Cargo.toml` - Modify: `Cargo.toml` (workspace root) - [x] **Step 1: Add notgraph to workspace** Edit `/Users/joe/dev/notfiles/Cargo.toml`, add `"crates/notgraph"` to the `members` array: ```toml [workspace] members = [ "crates/notcore", "crates/notfiles", "crates/notsecrets", "crates/nothooks", "crates/notstrap", "crates/notgraph", "tests/integration", ] ``` - [x] **Step 2: Create the crate manifest** Create `crates/notgraph/Cargo.toml`: ```toml [package] name = "notgraph" version = "0.1.0" edition = "2024" license.workspace = true [lib] name = "notgraph_lib" path = "src/lib.rs" [[bin]] name = "notgraph" path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { workspace = true } serde = { workspace = true } serde_json = "1" cargo_metadata = "0.18" syn = { version = "2", features = ["full", "visit"] } walkdir = "2" ``` - [x] **Step 3: Create stub lib.rs and main.rs** Create `crates/notgraph/src/lib.rs`: ```rust pub mod types; ``` Create `crates/notgraph/src/types.rs` (empty stub for now): ```rust // types defined in Task 2 ``` Create `crates/notgraph/src/main.rs`: ```rust fn main() { println!("notgraph"); } ``` - [x] **Step 4: Verify it builds** ``` cargo build -p notgraph ``` Expected: compiles cleanly. - [x] **Step 5: Commit** ``` git add crates/notgraph/ Cargo.toml Cargo.lock git commit -m "feat(notgraph): scaffold crate with lib+bin targets" ``` --- ## Task 2: Define all shared types **Files:** - Modify: `crates/notgraph/src/types.rs` - [x] **Step 1: Write the types module** Replace `crates/notgraph/src/types.rs` with: ```rust use serde::{Deserialize, Serialize}; pub type CrateName = String; pub type ModPath = String; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CrateGraph { pub nodes: Vec, pub edges: Vec<(CrateName, CrateName)>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModuleGraph { pub krate: CrateName, pub nodes: Vec, pub edges: Vec<(ModPath, ModPath)>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Symbol { pub mod_path: ModPath, pub kind: SymbolKind, pub name: String, pub is_pub: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SymbolKind { Struct, Enum, Trait, Fn, Type, Const, } impl std::fmt::Display for SymbolKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SymbolKind::Struct => write!(f, "struct"), SymbolKind::Enum => write!(f, "enum"), SymbolKind::Trait => write!(f, "trait"), SymbolKind::Fn => write!(f, "fn"), SymbolKind::Type => write!(f, "type"), SymbolKind::Const => write!(f, "const"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SymbolTable { pub krate: CrateName, pub symbols: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FanStats { pub name: String, pub fan_in: usize, pub fan_out: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModStats { pub krate: CrateName, pub nodes: Vec, pub cycles: Vec>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum HotspotKind { FanIn, FanOut, } impl std::fmt::Display for HotspotKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { HotspotKind::FanIn => write!(f, "fan-in"), HotspotKind::FanOut => write!(f, "fan-out"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Hotspot { pub name: String, pub kind: HotspotKind, pub score: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphStats { pub crate_graph: Vec, pub module_graphs: Vec, pub hotspots: Vec, pub cycles: Vec>, } ``` - [x] **Step 2: Verify it compiles** ``` cargo check -p notgraph ``` Expected: no errors. - [x] **Step 3: Commit** ``` git add crates/notgraph/src/types.rs git commit -m "feat(notgraph): define all shared types" ``` --- ## Task 3: Implement `crate_graph` **Files:** - Create: `crates/notgraph/src/crate_graph.rs` - Modify: `crates/notgraph/src/lib.rs` - [x] **Step 1: Write the implementation with unit test** Create `crates/notgraph/src/crate_graph.rs`: ```rust use crate::types::{CrateGraph, CrateName}; use anyhow::Result; pub fn build(manifest_path: &std::path::Path) -> Result { let meta = cargo_metadata::MetadataCommand::new() .manifest_path(manifest_path) .no_deps() .exec()?; let workspace_members: std::collections::HashSet = meta .workspace_members .iter() .filter_map(|id| meta.packages.iter().find(|p| &p.id == id)) .map(|p| p.name.clone()) .collect(); let nodes: Vec = workspace_members.iter().cloned().collect(); let mut edges: Vec<(CrateName, CrateName)> = Vec::new(); for pkg in meta.packages.iter().filter(|p| workspace_members.contains(&p.name)) { for dep in &pkg.dependencies { if workspace_members.contains(&dep.name) { edges.push((pkg.name.clone(), dep.name.clone())); } } } Ok(CrateGraph { nodes, edges }) } #[cfg(test)] mod tests { use super::*; #[test] fn builds_crate_graph_for_workspace() { let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent().unwrap() .parent().unwrap() .join("Cargo.toml"); let graph = build(&manifest).unwrap(); assert!(graph.nodes.contains(&"notgraph".to_string())); assert!(graph.nodes.contains(&"notcore".to_string())); assert!(graph.edges.contains(&("notstrap".to_string(), "notfiles".to_string()))); assert!(!graph.edges.iter().any(|(from, _)| from == "notcore")); } } ``` - [x] **Step 2: Add to lib.rs** Replace `crates/notgraph/src/lib.rs`: ```rust pub mod analysis; pub mod crate_graph; pub mod emit; pub mod module_graph; pub mod symbols; pub mod types; ``` (All modules declared even though some are stubs — add stub files for the ones not yet created.) Create stub `crates/notgraph/src/analysis.rs`: ```rust // implemented in Task 6 ``` Create stub `crates/notgraph/src/emit.rs`: ```rust // implemented in Task 7 ``` Create stub `crates/notgraph/src/module_graph.rs`: ```rust // implemented in Task 4 ``` Create stub `crates/notgraph/src/symbols.rs`: ```rust // implemented in Task 5 ``` - [x] **Step 3: Run test** ``` cargo test -p notgraph crate_graph ``` Expected: 1 test passed. - [x] **Step 4: Commit** ``` git add crates/notgraph/src/ git commit -m "feat(notgraph): implement crate_graph collector" ``` --- ## Task 4: Implement `module_graph` **Files:** - Modify: `crates/notgraph/src/module_graph.rs` - Create: fixture files - [x] **Step 1: Create clean fixture** Create `crates/notgraph/tests/fixtures/clean/src/lib.rs`: ```rust pub mod alpha; pub mod beta; ``` Create `crates/notgraph/tests/fixtures/clean/src/alpha.rs`: ```rust pub fn hello() -> &'static str { "alpha" } ``` Create `crates/notgraph/tests/fixtures/clean/src/beta.rs`: ```rust pub fn greet() -> &'static str { "beta" } ``` - [x] **Step 2: Write module_graph implementation** Replace `crates/notgraph/src/module_graph.rs`: ```rust use crate::types::{CrateName, ModPath, ModuleGraph}; use anyhow::Result; use std::path::Path; use syn::visit::Visit; use walkdir::WalkDir; struct ModCollector { current_mod: ModPath, edges: Vec<(ModPath, ModPath)>, nodes: Vec, } impl ModCollector { fn new(root_mod: ModPath) -> Self { Self { current_mod: root_mod.clone(), edges: Vec::new(), nodes: vec![root_mod], } } } impl<'ast> Visit<'ast> for ModCollector { fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { let child = format!("{}::{}", self.current_mod, node.ident); if !self.nodes.contains(&child) { self.nodes.push(child.clone()); } self.edges.push((self.current_mod.clone(), child.clone())); let parent = std::mem::replace(&mut self.current_mod, child); syn::visit::visit_item_mod(self, node); self.current_mod = parent; } } fn path_to_mod(krate: &str, rel: &Path) -> ModPath { let mut parts: Vec = vec![krate.to_string()]; for component in rel.components() { let s = component.as_os_str().to_string_lossy(); if s == "lib.rs" || s == "main.rs" { break; } let s = s.trim_end_matches(".rs").to_string(); if s != "mod" { parts.push(s); } } parts.join("::") } pub fn build(krate: CrateName, src_dir: &Path) -> Result { let mut all_nodes: Vec = Vec::new(); let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new(); for entry in WalkDir::new(src_dir) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map_or(false, |x| x == "rs")) { let path = entry.path(); let content = std::fs::read_to_string(path)?; let file = syn::parse_file(&content)?; let rel = path.strip_prefix(src_dir)?; let mod_path = path_to_mod(&krate, rel); let mut collector = ModCollector::new(mod_path); collector.visit_file(&file); for node in collector.nodes { if !all_nodes.contains(&node) { all_nodes.push(node); } } all_edges.extend(collector.edges); } all_edges.sort(); all_edges.dedup(); Ok(ModuleGraph { krate, nodes: all_nodes, edges: all_edges }) } #[cfg(test)] mod tests { use super::*; #[test] fn clean_fixture_has_expected_nodes() { let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/clean/src"); let graph = build("clean".to_string(), &fixture).unwrap(); assert!(graph.nodes.contains(&"clean".to_string())); assert!(graph.nodes.contains(&"clean::alpha".to_string())); assert!(graph.nodes.contains(&"clean::beta".to_string())); } #[test] fn clean_fixture_has_edges_from_lib() { let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/clean/src"); let graph = build("clean".to_string(), &fixture).unwrap(); assert!(graph.edges.contains(&("clean".to_string(), "clean::alpha".to_string()))); assert!(graph.edges.contains(&("clean".to_string(), "clean::beta".to_string()))); } } ``` - [x] **Step 3: Run tests** ``` cargo test -p notgraph module_graph ``` Expected: 2 tests passed. - [x] **Step 4: Commit** ``` git add crates/notgraph/src/module_graph.rs crates/notgraph/tests/fixtures/clean/ git commit -m "feat(notgraph): implement module_graph collector" ``` --- ## Task 5: Implement `symbols` **Files:** - Modify: `crates/notgraph/src/symbols.rs` - Create: `crates/notgraph/tests/fixtures/symbols/src/lib.rs` - [x] **Step 1: Create symbols fixture** Create `crates/notgraph/tests/fixtures/symbols/src/lib.rs`: ```rust pub struct Foo; pub enum Bar { A, B } pub trait Baz { fn run(&self); } pub fn qux() {} pub type Alias = u32; pub const VALUE: u32 = 42; struct Private; ``` - [x] **Step 2: Write symbols implementation** Replace `crates/notgraph/src/symbols.rs`: ```rust use crate::types::{CrateName, ModPath, Symbol, SymbolKind, SymbolTable}; use anyhow::Result; use std::path::Path; use syn::{Visibility, visit::Visit}; use walkdir::WalkDir; struct SymbolCollector { mod_path: ModPath, symbols: Vec, } impl SymbolCollector { fn new(mod_path: ModPath) -> Self { Self { mod_path, symbols: Vec::new() } } fn is_pub(vis: &Visibility) -> bool { matches!(vis, Visibility::Public(_)) } fn push(&mut self, kind: SymbolKind, name: String, is_pub: bool) { self.symbols.push(Symbol { mod_path: self.mod_path.clone(), kind, name, is_pub }); } } impl<'ast> Visit<'ast> for SymbolCollector { fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { self.push(SymbolKind::Struct, node.ident.to_string(), Self::is_pub(&node.vis)); } fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) { self.push(SymbolKind::Enum, node.ident.to_string(), Self::is_pub(&node.vis)); } fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { self.push(SymbolKind::Trait, node.ident.to_string(), Self::is_pub(&node.vis)); } fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { self.push(SymbolKind::Fn, node.sig.ident.to_string(), Self::is_pub(&node.vis)); } fn visit_item_type(&mut self, node: &'ast syn::ItemType) { self.push(SymbolKind::Type, node.ident.to_string(), Self::is_pub(&node.vis)); } fn visit_item_const(&mut self, node: &'ast syn::ItemConst) { self.push(SymbolKind::Const, node.ident.to_string(), Self::is_pub(&node.vis)); } } fn path_to_mod(krate: &str, rel: &Path) -> ModPath { let mut parts: Vec = vec![krate.to_string()]; for component in rel.components() { let s = component.as_os_str().to_string_lossy(); if s == "lib.rs" || s == "main.rs" { break; } let s = s.trim_end_matches(".rs").to_string(); if s != "mod" { parts.push(s); } } parts.join("::") } pub fn build(krate: CrateName, src_dir: &Path) -> Result { let mut symbols: Vec = Vec::new(); for entry in WalkDir::new(src_dir) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map_or(false, |x| x == "rs")) { let path = entry.path(); let content = std::fs::read_to_string(path)?; let file = syn::parse_file(&content)?; let rel = path.strip_prefix(src_dir)?; let mod_path = path_to_mod(&krate, rel); let mut collector = SymbolCollector::new(mod_path); collector.visit_file(&file); symbols.extend(collector.symbols); } Ok(SymbolTable { krate, symbols }) } #[cfg(test)] mod tests { use super::*; fn fixture_table() -> SymbolTable { let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/symbols/src"); build("symbols".to_string(), &fixture).unwrap() } #[test] fn detects_pub_struct() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub)); } #[test] fn detects_pub_enum() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub)); } #[test] fn detects_pub_trait() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub)); } #[test] fn detects_pub_fn() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub)); } #[test] fn detects_pub_type_alias() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub)); } #[test] fn detects_pub_const() { let t = fixture_table(); assert!(t.symbols.iter().any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub)); } #[test] fn private_struct_is_not_pub() { let t = fixture_table(); let p = t.symbols.iter().find(|s| s.name == "Private").unwrap(); assert!(!p.is_pub); } } ``` - [x] **Step 3: Run tests** ``` cargo test -p notgraph symbols ``` Expected: 7 tests passed. - [x] **Step 4: Commit** ``` git add crates/notgraph/src/symbols.rs crates/notgraph/tests/fixtures/symbols/ git commit -m "feat(notgraph): implement symbols extractor" ``` --- ## Task 6: Implement `analysis` **Files:** - Modify: `crates/notgraph/src/analysis.rs` - Create: `crates/notgraph/tests/fixtures/cyclic/src/` files - [x] **Step 1: Create cyclic fixture** Create `crates/notgraph/tests/fixtures/cyclic/src/lib.rs`: ```rust pub mod foo; pub mod bar; ``` Create `crates/notgraph/tests/fixtures/cyclic/src/foo.rs`: ```rust pub mod bar; ``` Create `crates/notgraph/tests/fixtures/cyclic/src/bar.rs`: ```rust pub mod foo; ``` - [x] **Step 2: Write analysis implementation** Replace `crates/notgraph/src/analysis.rs`: ```rust use crate::types::{ CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph, }; use std::collections::{HashMap, VecDeque}; pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec { let mut fan_in: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); let mut fan_out: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); for (from, to) in edges { *fan_out.entry(from.as_str()).or_insert(0) += 1; *fan_in.entry(to.as_str()).or_insert(0) += 1; } nodes.iter().map(|n| FanStats { name: n.clone(), fan_in: *fan_in.get(n.as_str()).unwrap_or(&0), fan_out: *fan_out.get(n.as_str()).unwrap_or(&0), }).collect() } /// Kahn's algorithm. Returns cycle participants if graph is not a DAG. pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec> { let mut in_degree: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); let mut adj: HashMap<&str, Vec<&str>> = nodes.iter().map(|n| (n.as_str(), vec![])).collect(); for (from, to) in edges { *in_degree.entry(to.as_str()).or_insert(0) += 1; adj.entry(from.as_str()).or_default().push(to.as_str()); } let mut queue: VecDeque<&str> = in_degree.iter() .filter(|(_, &d)| d == 0) .map(|(n, _)| *n) .collect(); let mut visited = 0usize; while let Some(node) = queue.pop_front() { visited += 1; for &neighbour in adj.get(node).unwrap_or(&vec![]) { let deg = in_degree.entry(neighbour).or_insert(0); *deg -= 1; if *deg == 0 { queue.push_back(neighbour); } } } if visited == nodes.len() { return vec![]; } let cycle_nodes: Vec = in_degree.iter() .filter(|(_, &d)| d > 0) .map(|(n, _)| n.to_string()) .collect(); vec![cycle_nodes] } pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec { let mut result = Vec::new(); let mut by_fan_in = stats.to_vec(); by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in)); for s in by_fan_in.iter().take(top_n) { if s.fan_in > 0 { result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanIn, score: s.fan_in }); } } let mut by_fan_out = stats.to_vec(); by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out)); for s in by_fan_out.iter().take(top_n) { if s.fan_out > 0 { result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanOut, score: s.fan_out }); } } result } pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: usize) -> GraphStats { let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges); let crate_hotspots = hotspots(&crate_stats, top_n); let mut all_cycles: Vec> = Vec::new(); let mut mod_stats_list: Vec = Vec::new(); for mg in module_graphs { let node_stats = fan_stats(&mg.nodes, &mg.edges); let cycles = detect_cycles(&mg.nodes, &mg.edges); all_cycles.extend(cycles.clone()); mod_stats_list.push(ModStats { krate: mg.krate.clone(), nodes: node_stats, cycles }); } let all_mod_fan: Vec = mod_stats_list.iter() .flat_map(|ms| ms.nodes.iter().cloned()) .collect(); let mod_hotspots = hotspots(&all_mod_fan, top_n); let mut all_hotspots = crate_hotspots; all_hotspots.extend(mod_hotspots); GraphStats { crate_graph: crate_stats, module_graphs: mod_stats_list, hotspots: all_hotspots, cycles: all_cycles, } } #[cfg(test)] mod tests { use super::*; #[test] fn fan_stats_counts_correctly() { let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()]; let edges = vec![ ("a".to_string(), "b".to_string()), ("a".to_string(), "c".to_string()), ("b".to_string(), "c".to_string()), ]; let stats = fan_stats(&nodes, &edges); let a = stats.iter().find(|s| s.name == "a").unwrap(); let c = stats.iter().find(|s| s.name == "c").unwrap(); assert_eq!(a.fan_out, 2); assert_eq!(a.fan_in, 0); assert_eq!(c.fan_in, 2); assert_eq!(c.fan_out, 0); } #[test] fn no_cycle_in_acyclic_graph() { let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()]; let edges = vec![ ("a".to_string(), "b".to_string()), ("b".to_string(), "c".to_string()), ]; assert!(detect_cycles(&nodes, &edges).is_empty()); } #[test] fn detects_cycle_in_cyclic_graph() { let nodes = vec!["a".to_string(), "b".to_string()]; let edges = vec![ ("a".to_string(), "b".to_string()), ("b".to_string(), "a".to_string()), ]; let cycles = detect_cycles(&nodes, &edges); assert!(!cycles.is_empty()); } #[test] fn hotspots_picks_top_n_by_fan_in_and_fan_out() { let stats = vec![ FanStats { name: "a".to_string(), fan_in: 5, fan_out: 1 }, FanStats { name: "b".to_string(), fan_in: 3, fan_out: 2 }, FanStats { name: "c".to_string(), fan_in: 1, fan_out: 4 }, ]; let spots = hotspots(&stats, 1); let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap(); let fo = spots.iter().find(|h| h.kind == HotspotKind::FanOut).unwrap(); assert_eq!(fi.name, "a"); assert_eq!(fo.name, "c"); } } ``` - [x] **Step 3: Run tests** ``` cargo test -p notgraph analysis ``` Expected: 4 tests passed. - [x] **Step 4: Commit** ``` git add crates/notgraph/src/analysis.rs crates/notgraph/tests/fixtures/cyclic/ git commit -m "feat(notgraph): implement analysis (fan stats, Kahn cycles, hotspots)" ``` --- ## Task 7: Implement `emit` **Files:** - Modify: `crates/notgraph/src/emit.rs` - [x] **Step 1: Write emit implementation** Replace `crates/notgraph/src/emit.rs`: ```rust use crate::types::{GraphStats, HotspotKind, SymbolKind, SymbolTable}; use anyhow::Result; use std::path::Path; pub fn write_all(output_dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) -> Result<()> { std::fs::create_dir_all(output_dir)?; write_json(output_dir, stats)?; write_markdown(output_dir, stats, symbol_tables)?; write_html(output_dir, stats)?; Ok(()) } fn write_json(dir: &Path, stats: &GraphStats) -> Result<()> { std::fs::write(dir.join("report.json"), serde_json::to_string_pretty(stats)?)?; Ok(()) } fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) -> Result<()> { let mut md = String::new(); md.push_str("# notgraph Report\n\n"); md.push_str("## Crate Dependency Graph\n\n"); for fs in &stats.crate_graph { md.push_str(&format!("- **{}** (fan-in: {}, fan-out: {})\n", fs.name, fs.fan_in, fs.fan_out)); } md.push('\n'); md.push_str("## Module Graphs\n\n"); for ms in &stats.module_graphs { let status = if ms.cycles.is_empty() { "OK" } else { "CYCLES DETECTED" }; md.push_str(&format!( "### {} [{}]\n\n- Modules: {}\n- Cycles: {}\n\n", ms.krate, status, ms.nodes.len(), ms.cycles.len() )); } md.push_str("## Hotspots\n\n### Fan-in (most depended-upon)\n\n"); md.push_str("| Name | Score |\n|------|-------|\n"); for h in stats.hotspots.iter().filter(|h| h.kind == HotspotKind::FanIn) { md.push_str(&format!("| {} | {} |\n", h.name, h.score)); } md.push('\n'); md.push_str("### Fan-out (highest coupling)\n\n"); md.push_str("| Name | Score |\n|------|-------|\n"); for h in stats.hotspots.iter().filter(|h| h.kind == HotspotKind::FanOut) { md.push_str(&format!("| {} | {} |\n", h.name, h.score)); } md.push('\n'); md.push_str("## Public Symbol Inventory\n\n"); for table in symbol_tables { md.push_str(&format!("### {}\n\n", table.krate)); for kind in [SymbolKind::Struct, SymbolKind::Enum, SymbolKind::Trait, SymbolKind::Fn, SymbolKind::Type, SymbolKind::Const] { let syms: Vec<&str> = table.symbols.iter() .filter(|s| s.kind == kind && s.is_pub) .map(|s| s.name.as_str()) .collect(); if !syms.is_empty() { md.push_str(&format!("**{}**: {}\n\n", kind, syms.join(", "))); } } } md.push_str("## Cycle Report\n\n"); if stats.cycles.is_empty() { md.push_str("No cycles detected.\n"); } else { for cycle in &stats.cycles { md.push_str(&format!("- CYCLE: {}\n", cycle.join(" -> "))); } } std::fs::write(dir.join("report.md"), md)?; Ok(()) } fn write_html(dir: &Path, stats: &GraphStats) -> Result<()> { let nodes_js: String = stats.crate_graph.iter().enumerate() .map(|(i, n)| format!("{{id:{},label:{:?},title:'fan-in:{} fan-out:{}'}}", i, n.name, n.fan_in, n.fan_out)) .collect::>() .join(","); let cycle_html = if stats.cycles.is_empty() { "

No cycles detected.

".to_string() } else { stats.cycles.iter() .map(|c| format!("

CYCLE: {}

", c.join(" -> "))) .collect::>() .join("\n") }; let hotspot_rows: String = stats.hotspots.iter() .map(|h| format!("{}{}{}", h.name, h.kind, h.score)) .collect::>() .join("\n"); let html = format!( include_str!("../templates/report.html.template"), nodes_js = nodes_js, hotspot_rows = hotspot_rows, cycle_html = cycle_html, ); std::fs::write(dir.join("report.html"), html)?; Ok(()) } ``` - [x] **Step 2: Create the HTML template** Create `crates/notgraph/templates/report.html.template`: ```html notgraph Report

notgraph Report

Crate Dependency Graph

Hotspots

{hotspot_rows}
Name Kind Score

Cycle Report

{cycle_html} ``` - [x] **Step 3: Verify it compiles** ``` cargo check -p notgraph ``` Expected: no errors. - [x] **Step 4: Commit** ``` git add crates/notgraph/src/emit.rs crates/notgraph/templates/ git commit -m "feat(notgraph): implement emit (md, json, html)" ``` --- ## Task 8: Wire main.rs CLI **Files:** - Modify: `crates/notgraph/src/main.rs` - [x] **Step 1: Replace main.rs with full CLI** Replace `crates/notgraph/src/main.rs`: ```rust use notgraph_lib::{analysis, crate_graph, emit, module_graph, symbols}; use anyhow::{Context, Result}; use clap::Parser; use std::path::PathBuf; #[derive(Parser)] #[command(name = "notgraph", about = "Workspace module graph and symbol analysis")] struct Cli { #[arg(long, default_value = "docs/graph")] output: PathBuf, #[arg(long)] fail_on_cycles: bool, #[arg(long, default_value_t = 10)] top: usize, } fn main() -> Result<()> { let cli = Cli::parse(); let manifest = find_workspace_manifest()?; let workspace_root = manifest.parent().unwrap().to_path_buf(); let crate_g = crate_graph::build(&manifest) .context("failed to build crate graph")?; let meta = cargo_metadata::MetadataCommand::new() .manifest_path(&manifest) .no_deps() .exec()?; let mut module_graphs = Vec::new(); let mut symbol_tables = Vec::new(); for pkg in &meta.packages { if !meta.workspace_members.contains(&pkg.id) { continue; } let src_dir = PathBuf::from(pkg.manifest_path.as_str()) .parent().unwrap().join("src"); if !src_dir.exists() { continue; } module_graphs.push( module_graph::build(pkg.name.clone(), &src_dir) .with_context(|| format!("module_graph failed for {}", pkg.name))? ); symbol_tables.push( symbols::build(pkg.name.clone(), &src_dir) .with_context(|| format!("symbols failed for {}", pkg.name))? ); } let stats = analysis::analyse(&crate_g, &module_graphs, cli.top); let output_dir = if cli.output.is_absolute() { cli.output.clone() } else { workspace_root.join(&cli.output) }; emit::write_all(&output_dir, &stats, &symbol_tables) .context("failed to write reports")?; println!("notgraph: reports written to {}", output_dir.display()); if cli.fail_on_cycles && !stats.cycles.is_empty() { eprintln!("notgraph: {} cycle(s) detected", stats.cycles.len()); for cycle in &stats.cycles { eprintln!(" {}", cycle.join(" -> ")); } std::process::exit(1); } Ok(()) } fn find_workspace_manifest() -> Result { let mut dir = std::env::current_dir()?; loop { let candidate = dir.join("Cargo.toml"); if candidate.exists() { let content = std::fs::read_to_string(&candidate)?; if content.contains("[workspace]") { return Ok(candidate); } } anyhow::ensure!(dir.pop(), "could not find workspace Cargo.toml"); } } ``` - [x] **Step 2: Build and smoke-test** ``` cargo run -p notgraph -- --output /tmp/notgraph-test ``` Expected: `notgraph: reports written to /tmp/notgraph-test` ``` ls /tmp/notgraph-test ``` Expected: `report.json report.md report.html` - [x] **Step 3: Verify --fail-on-cycles exits 0 on clean workspace** ``` cargo run -p notgraph -- --output /tmp/notgraph-test --fail-on-cycles; echo "exit $?" ``` Expected: `exit 0` - [x] **Step 4: Run clippy** ``` cargo clippy -p notgraph -- -D warnings ``` Fix any warnings. - [x] **Step 5: Commit** ``` git add crates/notgraph/src/main.rs git commit -m "feat(notgraph): wire full CLI pipeline" ``` --- ## Task 9: Integration tests **Files:** - Create: `crates/notgraph/tests/integration.rs` - [x] **Step 1: Write integration tests** Create `crates/notgraph/tests/integration.rs`: ```rust use notgraph_lib::{analysis, module_graph, symbols}; use std::path::Path; fn fixtures(name: &str) -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures") .join(name) .join("src") } #[test] fn clean_fixture_has_no_cycles() { let mg = module_graph::build("clean".to_string(), &fixtures("clean")).unwrap(); assert!( analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(), "expected no cycles; nodes={:?} edges={:?}", mg.nodes, mg.edges ); } #[test] fn cyclic_fixture_has_cycles() { let mg = module_graph::build("cyclic".to_string(), &fixtures("cyclic")).unwrap(); let cycles = analysis::detect_cycles(&mg.nodes, &mg.edges); assert!( !cycles.is_empty(), "expected cycles; nodes={:?} edges={:?}", mg.nodes, mg.edges ); } #[test] fn symbols_fixture_has_six_public_symbols() { let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap(); let pub_count = table.symbols.iter().filter(|s| s.is_pub).count(); assert_eq!( pub_count, 6, "expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}", table.symbols.iter().filter(|s| s.is_pub).map(|s| &s.name).collect::>() ); } ``` - [x] **Step 2: Run integration tests** ``` cargo test -p notgraph --test integration ``` Expected: 3 tests passed. - [x] **Step 3: Run all notgraph tests** ``` cargo test -p notgraph ``` Expected: all tests pass. - [x] **Step 4: Commit** ``` git add crates/notgraph/tests/integration.rs git commit -m "test(notgraph): add integration tests for clean/cyclic/symbols fixtures" ``` --- ## Task 10: mise wiring, .gitignore, complete todo **Files:** - Modify: `mise.toml` - Modify or create: `.gitignore` - [x] **Step 1: Add graph tasks to mise.toml** In `mise.toml`, add after `[tasks."all:dep-boundaries"]`: ```toml [tasks."all:graph"] description = "Generate module graph reports (docs/graph/)" run = "cargo run -p notgraph --release -- --output docs/graph" [tasks."all:graph-check"] description = "CI: fail if module cycles detected" run = "cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles" ``` - [x] **Step 2: Add graph-check to the all:ci gate** In `mise.toml`, in the `all:ci` task run script, add after the dep-boundaries line: ```bash echo "── graph-check ──────────────────────" cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles ``` - [x] **Step 3: Gitignore generated reports** Add to `.gitignore` (create file at workspace root if it doesn't exist): ``` docs/graph/ ``` - [x] **Step 4: Run full CI gate** ``` mise run all:ci ``` Expected: all gates pass, exits 0. - [x] **Step 5: Mark todo complete** ``` doob todo complete ubj2z5rs9wxiktlt0fwp ``` - [x] **Step 6: Commit** ``` git add mise.toml .gitignore git commit -m "feat(notgraph): wire all:graph and all:graph-check into mise CI" ``` --- ## Self-Review **Spec coverage:** - CrateGraph from cargo_metadata → Task 3 - ModuleGraph per crate (walkdir + syn) → Task 4 - SymbolTable per crate → Task 5 - Fan-in/fan-out, Kahn cycle detection, hotspot ranking → Task 6 - report.md, report.json, report.html → Task 7 - CLI (--output, --fail-on-cycles, --top) → Task 8 - Integration tests (clean, cyclic, symbols fixtures) → Task 9 - mise wiring and .gitignore → Task 10 **Type consistency:** All types defined in Task 2 types.rs and referenced consistently by field name throughout (`krate` not `crate`, `fan_in`/`fan_out` not `fanIn`/`fanOut`). **Known limitation:** HTML crate graph shows nodes but no edges — edges are not propagated into GraphStats (only fan stats). To add edges: pass `CrateGraph` directly to `emit::write_all`. Left as a follow-up.