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

40 KiB

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:

[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:

[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:

pub mod types;

Create crates/notgraph/src/types.rs (empty stub for now):

// types defined in Task 2

Create crates/notgraph/src/main.rs:

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:

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>>,
}
  • 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:

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"));
    }
}
  • Step 2: Add to lib.rs

Replace crates/notgraph/src/lib.rs:

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:

// implemented in Task 6

Create stub crates/notgraph/src/emit.rs:

// implemented in Task 7

Create stub crates/notgraph/src/module_graph.rs:

// implemented in Task 4

Create stub crates/notgraph/src/symbols.rs:

// 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:

pub mod alpha;
pub mod beta;

Create crates/notgraph/tests/fixtures/clean/src/alpha.rs:

pub fn hello() -> &'static str { "alpha" }

Create crates/notgraph/tests/fixtures/clean/src/beta.rs:

pub fn greet() -> &'static str { "beta" }
  • Step 2: Write module_graph implementation

Replace crates/notgraph/src/module_graph.rs:

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())));
    }
}
  • 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:

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:

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);
    }
}
  • 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:

pub mod foo;
pub mod bar;

Create crates/notgraph/tests/fixtures/cyclic/src/foo.rs:

pub mod bar;

Create crates/notgraph/tests/fixtures/cyclic/src/bar.rs:

pub mod foo;
  • Step 2: Write analysis implementation

Replace crates/notgraph/src/analysis.rs:

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");
    }
}
  • 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:

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(())
}
  • Step 2: Create the HTML template

Create crates/notgraph/templates/report.html.template:

<!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>
  • 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:

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");
    }
}
  • 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:

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<_>>()
    );
}
  • 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"]:

[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:

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.