diff --git a/docs/superpowers/plans/2026-04-01-notgraph.md b/docs/superpowers/plans/2026-04-01-notgraph.md new file mode 100644 index 0000000..b6544e0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-notgraph.md @@ -0,0 +1,1432 @@ +# 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) + +- [ ] **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", +] +``` + +- [ ] **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" +``` + +- [ ] **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"); +} +``` + +- [ ] **Step 4: Verify it builds** + +``` +cargo build -p notgraph +``` + +Expected: compiles cleanly. + +- [ ] **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` + +- [ ] **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>, +} +``` + +- [ ] **Step 2: Verify it compiles** + +``` +cargo check -p notgraph +``` + +Expected: no errors. + +- [ ] **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` + +- [ ] **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")); + } +} +``` + +- [ ] **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 +``` + +- [ ] **Step 3: Run test** + +``` +cargo test -p notgraph crate_graph +``` + +Expected: 1 test passed. + +- [ ] **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 + +- [ ] **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" } +``` + +- [ ] **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()))); + } +} +``` + +- [ ] **Step 3: Run tests** + +``` +cargo test -p notgraph module_graph +``` + +Expected: 2 tests passed. + +- [ ] **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` + +- [ ] **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; +``` + +- [ ] **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); + } +} +``` + +- [ ] **Step 3: Run tests** + +``` +cargo test -p notgraph symbols +``` + +Expected: 7 tests passed. + +- [ ] **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 + +- [ ] **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; +``` + +- [ ] **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"); + } +} +``` + +- [ ] **Step 3: Run tests** + +``` +cargo test -p notgraph analysis +``` + +Expected: 4 tests passed. + +- [ ] **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` + +- [ ] **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(()) +} +``` + +- [ ] **Step 2: Create the HTML template** + +Create `crates/notgraph/templates/report.html.template`: + +```html + + + + + notgraph Report + + + + +

notgraph Report

+

Crate Dependency Graph

+
+ +

Hotspots

+ + + + + + + + + + {hotspot_rows} + +
NameKindScore
+

Cycle Report

+ {cycle_html} + + + +``` + +- [ ] **Step 3: Verify it compiles** + +``` +cargo check -p notgraph +``` + +Expected: no errors. + +- [ ] **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` + +- [ ] **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"); + } +} +``` + +- [ ] **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` + +- [ ] **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` + +- [ ] **Step 4: Run clippy** + +``` +cargo clippy -p notgraph -- -D warnings +``` + +Fix any warnings. + +- [ ] **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` + +- [ ] **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::>() + ); +} +``` + +- [ ] **Step 2: Run integration tests** + +``` +cargo test -p notgraph --test integration +``` + +Expected: 3 tests passed. + +- [ ] **Step 3: Run all notgraph tests** + +``` +cargo test -p notgraph +``` + +Expected: all tests pass. + +- [ ] **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` + +- [ ] **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" +``` + +- [ ] **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 +``` + +- [ ] **Step 3: Gitignore generated reports** + +Add to `.gitignore` (create file at workspace root if it doesn't exist): + +``` +docs/graph/ +``` + +- [ ] **Step 4: Run full CI gate** + +``` +mise run all:ci +``` + +Expected: all gates pass, exits 0. + +- [ ] **Step 5: Mark todo complete** + +``` +doob todo complete ubj2z5rs9wxiktlt0fwp +``` + +- [ ] **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.