1433 lines
40 KiB
Markdown
1433 lines
40 KiB
Markdown
# 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<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`:
|
|
|
|
```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"));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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(" -> ")))
|
|
.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`:
|
|
|
|
```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>
|
|
```
|
|
|
|
- [ ] **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<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`:
|
|
|
|
```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<_>>()
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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.
|