Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <george.moon@gmail.com>
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 88 additions & 8 deletions src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -357,12 +360,7 @@ fn check_duplicate_ids(root: &Path, issues: &mut Vec<LintIssue>) {
}

/// Check that edge references point to existing node IDs.
fn check_edge_references(root: &Path, issues: &mut Vec<LintIssue>) {
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<LintIssue>) {
for node in index.values() {
for edge_ref in node.all_edges() {
if !index.contains_key(&edge_ref.target) {
Expand All @@ -378,6 +376,41 @@ fn check_edge_references(root: &Path, issues: &mut Vec<LintIssue>) {
}
}

/// 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<LintIssue>) {
// 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<String> {
let mut fixed = Vec::new();
Expand Down Expand Up @@ -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 {
Expand Down
Loading