From 42bbb2f49e93fa3347c25a07e001cdfb7836cf26 Mon Sep 17 00:00:00 2001 From: George Moon Date: Sat, 14 Mar 2026 21:03:27 -0400 Subject: [PATCH] Add orphan node detection to lattice lint Warns on nodes with no inbound or outbound edges, indicating incomplete graph wiring. Also refactors lint to build the node index once and share it across cross-node checks (edge references + orphan detection). Closes #16 Implements REQ-LINT-001 Co-Authored-By: Claude Opus 4.6 --- ...t-warns-on-orphan-nodes-with-no-edges.yaml | 18 ++++ Cargo.lock | 2 +- src/lint.rs | 96 +++++++++++++++++-- 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 .lattice/requirements/lint/001-lint-warns-on-orphan-nodes-with-no-edges.yaml diff --git a/.lattice/requirements/lint/001-lint-warns-on-orphan-nodes-with-no-edges.yaml b/.lattice/requirements/lint/001-lint-warns-on-orphan-nodes-with-no-edges.yaml new file mode 100644 index 0000000..b17f4b1 --- /dev/null +++ b/.lattice/requirements/lint/001-lint-warns-on-orphan-nodes-with-no-edges.yaml @@ -0,0 +1,18 @@ +id: REQ-LINT-001 +type: requirement +title: Lint warns on orphan nodes with no edges +body: 'lattice lint should emit a warning for any node that has zero edges (no inbound or outbound connections). These orphan nodes indicate incomplete graph wiring. Every node type has expected edge directions: Source should have outbound supports, Thesis should have inbound supports and outbound derives, Requirement should have inbound derives, Implementation should have outbound satisfies.' +status: active +version: 1.0.0 +created_at: 2026-03-15T01:00:16.261409+00:00 +created_by: agent:claude-2026-03-15 +requested_by: George Moon +priority: P1 +category: lint +edges: + derives_from: + - target: THX-BIDIRECTIONAL-FLOW + version: 1.0.0 + depends_on: + - target: REQ-CORE-012 + version: 1.0.0 diff --git a/Cargo.lock b/Cargo.lock index 2a1a467..a6c8623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,7 +1566,7 @@ dependencies = [ [[package]] name = "lattice" -version = "0.1.10" +version = "0.1.14" dependencies = [ "assert_cmd", "chrono", diff --git a/src/lint.rs b/src/lint.rs index b2241ec..c11f6ec 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -153,8 +153,11 @@ pub fn lint_lattice(root: &Path) -> LintReport { // Check for duplicate IDs check_duplicate_ids(root, &mut issues); - // Check edge references point to existing nodes - check_edge_references(root, &mut issues); + // Build node index once for cross-node checks + if let Ok(index) = crate::graph::build_node_index(root) { + check_edge_references(&index, &mut issues); + check_orphan_nodes(&index, &mut issues); + } LintReport { issues } } @@ -357,12 +360,7 @@ fn check_duplicate_ids(root: &Path, issues: &mut Vec) { } /// Check that edge references point to existing node IDs. -fn check_edge_references(root: &Path, issues: &mut Vec) { - let index = match crate::graph::build_node_index(root) { - Ok(idx) => idx, - Err(_) => return, - }; - +fn check_edge_references(index: &crate::types::NodeIndex, issues: &mut Vec) { for node in index.values() { for edge_ref in node.all_edges() { if !index.contains_key(&edge_ref.target) { @@ -378,6 +376,41 @@ fn check_edge_references(root: &Path, issues: &mut Vec) { } } +/// Check for orphan nodes that have no edges (no inbound or outbound connections). +/// +/// Linked requirements: REQ-LINT-001 +fn check_orphan_nodes(index: &crate::types::NodeIndex, issues: &mut Vec) { + // Collect all node IDs that participate in any edge (as source or target) + let mut connected: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + for node in index.values() { + for edge_ref in node.all_edges() { + connected.insert(&node.id); + connected.insert(&edge_ref.target); + } + } + + // Any node not in the connected set is an orphan + let mut orphans: Vec<&crate::types::LatticeNode> = index + .values() + .filter(|n| !connected.contains(n.id.as_str())) + .collect(); + orphans.sort_by(|a, b| a.id.cmp(&b.id)); + + for node in &orphans { + issues.push(LintIssue { + file: PathBuf::from(format!("<{}>", node.id)), + node_id: Some(node.id.clone()), + severity: LintSeverity::Warning, + message: format!( + "Node has no edges (orphan) — expected at least one connection for {:?}", + node.node_type + ), + fixable: Fixable::No, + }); + } +} + /// Apply auto-fixes for fixable issues. pub fn fix_issues(root: &Path, report: &LintReport) -> Vec { let mut fixed = Vec::new(); @@ -555,6 +588,53 @@ mod tests { assert!(lattice_dir.join("config.yaml").exists()); } + #[test] + fn test_lint_orphan_node_warned() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + crate::storage::init_lattice(root, false).unwrap(); + + // Create a node with no edges — should be flagged as orphan + let req_dir = root.join(LATTICE_DIR).join("requirements"); + fs::write( + req_dir.join("orphan.yaml"), + "id: REQ-ORPHAN\ntype: requirement\ntitle: Orphan\nbody: Body\nstatus: active\nversion: '1.0.0'\ncreated_at: '2026-01-01'\ncreated_by: test\npriority: P0\n", + ).unwrap(); + + let report = lint_lattice(root); + let warnings = report.warnings(); + assert!( + warnings.iter().any(|w| w.message.contains("orphan") && w.node_id.as_deref() == Some("REQ-ORPHAN")), + "Expected orphan warning for REQ-ORPHAN" + ); + } + + #[test] + fn test_lint_connected_node_not_orphan() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + crate::storage::init_lattice(root, false).unwrap(); + + // Create two nodes connected by an edge + let req_dir = root.join(LATTICE_DIR).join("requirements"); + fs::write( + req_dir.join("parent.yaml"), + "id: REQ-PARENT\ntype: requirement\ntitle: Parent\nbody: Body\nstatus: active\nversion: '1.0.0'\ncreated_at: '2026-01-01'\ncreated_by: test\npriority: P0\n", + ).unwrap(); + fs::write( + req_dir.join("child.yaml"), + "id: REQ-CHILD\ntype: requirement\ntitle: Child\nbody: Body\nstatus: active\nversion: '1.0.0'\ncreated_at: '2026-01-01'\ncreated_by: test\npriority: P0\nedges:\n depends_on:\n - target: REQ-PARENT\n version: '1.0.0'\n", + ).unwrap(); + + let report = lint_lattice(root); + let warnings = report.warnings(); + // Neither should be flagged as orphan + assert!( + !warnings.iter().any(|w| w.message.contains("orphan")), + "Connected nodes should not be flagged as orphans" + ); + } + #[test] fn test_lint_report_display() { let issue = LintIssue {