Files
notfiles/docs/superpowers/plans/2026-04-01-notgraph.md

1433 lines
40 KiB
Markdown
Raw Permalink Normal View History

2026-04-03 11:59:46 -04:00
# 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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
```
cargo build -p notgraph
```
Expected: compiles cleanly.
- [x] **Step 5: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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<CrateName>,
pub edges: Vec<(CrateName, CrateName)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleGraph {
pub krate: CrateName,
pub nodes: Vec<ModPath>,
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<Symbol>,
}
#[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<FanStats>,
pub cycles: Vec<Vec<ModPath>>,
}
#[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<FanStats>,
pub module_graphs: Vec<ModStats>,
pub hotspots: Vec<Hotspot>,
pub cycles: Vec<Vec<ModPath>>,
}
```
- [x] **Step 2: Verify it compiles**
2026-04-03 11:59:46 -04:00
```
cargo check -p notgraph
```
Expected: no errors.
- [x] **Step 3: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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<CrateGraph> {
let meta = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_path)
.no_deps()
.exec()?;
let workspace_members: std::collections::HashSet<String> = meta
.workspace_members
.iter()
.filter_map(|id| meta.packages.iter().find(|p| &p.id == id))
.map(|p| p.name.clone())
.collect();
let nodes: Vec<CrateName> = 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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph crate_graph
```
Expected: 1 test passed.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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<ModPath>,
}
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<String> = 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<ModuleGraph> {
let mut all_nodes: Vec<ModPath> = 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**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph module_graph
```
Expected: 2 tests passed.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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<Symbol>,
}
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<String> = 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<SymbolTable> {
let mut symbols: Vec<Symbol> = 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**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph symbols
```
Expected: 7 tests passed.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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<FanStats> {
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<Vec<String>> {
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<String> = 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<Hotspot> {
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<String>> = Vec::new();
let mut mod_stats_list: Vec<ModStats> = 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<FanStats> = 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**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph analysis
```
Expected: 4 tests passed.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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::<Vec<_>>()
.join(",");
let cycle_html = if stats.cycles.is_empty() {
"<p>No cycles detected.</p>".to_string()
} else {
stats.cycles.iter()
.map(|c| format!("<p>CYCLE: {}</p>", c.join(" -&gt; ")))
.collect::<Vec<_>>()
.join("\n")
};
let hotspot_rows: String = stats.hotspots.iter()
.map(|h| format!("<tr><td>{}</td><td>{}</td><td>{}</td></tr>", h.name, h.kind, h.score))
.collect::<Vec<_>>()
.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**
2026-04-03 11:59:46 -04:00
Create `crates/notgraph/templates/report.html.template`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>notgraph Report</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
body {{ font-family: sans-serif; margin: 2rem; max-width: 1200px; }}
#graph {{ height: 400px; border: 1px solid #ccc; margin-bottom: 2rem; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }}
th, td {{ border: 1px solid #ccc; padding: 0.4rem 0.8rem; text-align: left; }}
th {{ background: #f5f5f5; cursor: pointer; user-select: none; }}
th:hover {{ background: #e8e8e8; }}
h2 {{ border-bottom: 2px solid #333; padding-bottom: 0.3rem; }}
</style>
</head>
<body>
<h1>notgraph Report</h1>
<h2>Crate Dependency Graph</h2>
<div id="graph"></div>
<script>
const nodes = new vis.DataSet([{nodes_js}]);
const edges = new vis.DataSet([]);
new vis.Network(document.getElementById('graph'), {{nodes, edges}}, {{
layout: {{ hierarchical: {{ direction: 'LR', sortMethod: 'directed' }} }},
physics: false,
nodes: {{ shape: 'box', color: {{ background: '#d4e8ff', border: '#336699' }} }}
}});
</script>
<h2>Hotspots</h2>
<table id="hotspots">
<thead>
<tr>
<th onclick="sortTable('hotspots',0,this)">Name</th>
<th onclick="sortTable('hotspots',1,this)">Kind</th>
<th onclick="sortTable('hotspots',2,this)">Score</th>
</tr>
</thead>
<tbody>
{hotspot_rows}
</tbody>
</table>
<h2>Cycle Report</h2>
{cycle_html}
<script>
function sortTable(id, col, th) {{
const t = document.getElementById(id);
const asc = th.dataset.dir !== 'asc';
th.dataset.dir = asc ? 'asc' : 'desc';
const rows = Array.from(t.querySelectorAll('tbody tr'));
rows.sort((a, b) => {{
const av = a.cells[col].textContent.trim();
const bv = b.cells[col].textContent.trim();
const cmp = av.localeCompare(bv, undefined, {{numeric: true}});
return asc ? cmp : -cmp;
}});
const tbody = t.querySelector('tbody');
rows.forEach(r => tbody.appendChild(r));
}}
</script>
</body>
</html>
```
- [x] **Step 3: Verify it compiles**
2026-04-03 11:59:46 -04:00
```
cargo check -p notgraph
```
Expected: no errors.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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<PathBuf> {
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**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
```
cargo run -p notgraph -- --output /tmp/notgraph-test --fail-on-cycles; echo "exit $?"
```
Expected: `exit 0`
- [x] **Step 4: Run clippy**
2026-04-03 11:59:46 -04:00
```
cargo clippy -p notgraph -- -D warnings
```
Fix any warnings.
- [x] **Step 5: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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::<Vec<_>>()
);
}
```
- [x] **Step 2: Run integration tests**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph --test integration
```
Expected: 3 tests passed.
- [x] **Step 3: Run all notgraph tests**
2026-04-03 11:59:46 -04:00
```
cargo test -p notgraph
```
Expected: all tests pass.
- [x] **Step 4: Commit**
2026-04-03 11:59:46 -04:00
```
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
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**
2026-04-03 11:59:46 -04:00
Add to `.gitignore` (create file at workspace root if it doesn't exist):
```
docs/graph/
```
- [x] **Step 4: Run full CI gate**
2026-04-03 11:59:46 -04:00
```
mise run all:ci
```
Expected: all gates pass, exits 0.
- [x] **Step 5: Mark todo complete**
2026-04-03 11:59:46 -04:00
```
doob todo complete ubj2z5rs9wxiktlt0fwp
```
- [x] **Step 6: Commit**
2026-04-03 11:59:46 -04:00
```
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.