Skip to main content

nodex_core/query/
issues.rs

1//! Unified issue report — single query that surfaces every actionable
2//! problem in the graph, so an AI agent can discover "what needs fixing"
3//! in a single round-trip instead of composing four separate queries.
4//!
5//! All collectors defer to existing functions; this module is pure
6//! composition and adds a summary aggregate.
7
8use serde::Serialize;
9use std::collections::BTreeMap;
10
11use crate::config::Config;
12use crate::model::{Edge, Graph, ResolvedTarget};
13use crate::rules::{Violation, check_all};
14
15use super::detect::{OrphanEntry, StaleEntry, find_orphans, find_stale};
16
17/// Stable category keys used in [`IssueSummary::by_category`].
18///
19/// Exposed as `const` so command-line consumers and tests reference the
20/// same identifiers; violations are reported as `violation_<rule_id>`.
21pub mod categories {
22    pub const ORPHAN: &str = "orphan";
23    pub const STALE: &str = "stale";
24    pub const UNRESOLVED_EDGE: &str = "unresolved_edge";
25    pub const VIOLATION_PREFIX: &str = "violation_";
26}
27
28/// A single unresolved outgoing edge. Surfaced so the agent can fix the
29/// dangling reference (rename, create missing doc, or delete the link).
30#[derive(Debug, Clone, Serialize)]
31pub struct UnresolvedEdge {
32    pub source_id: String,
33    pub source_path: String,
34    pub relation: String,
35    pub raw_target: String,
36    pub reason: String,
37    pub location: String,
38}
39
40/// Aggregate of all actionable problems in the graph.
41#[derive(Debug, Serialize)]
42pub struct IssueReport {
43    pub orphans: Vec<OrphanEntry>,
44    pub stale: Vec<StaleEntry>,
45    pub unresolved_edges: Vec<UnresolvedEdge>,
46    pub violations: Vec<Violation>,
47    pub summary: IssueSummary,
48}
49
50/// Counts by category for quick triage. Uses [`BTreeMap`] so the
51/// serialized JSON key order is deterministic.
52#[derive(Debug, Serialize)]
53pub struct IssueSummary {
54    pub total: usize,
55    pub by_category: BTreeMap<String, usize>,
56}
57
58/// Build the full issue report.
59///
60/// This is intentionally a pure function over the graph — every field
61/// can be computed by an external caller using existing APIs; this
62/// exists so the common AI-agent question "what's broken?" resolves in
63/// a single call.
64pub fn collect_issues(graph: &Graph, config: &Config) -> IssueReport {
65    let orphans = find_orphans(graph, config);
66    let stale = find_stale(graph, config);
67    let unresolved_edges = find_unresolved_edges(graph);
68    let violations = check_all(graph, config);
69
70    let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
71    if !orphans.is_empty() {
72        by_category.insert(categories::ORPHAN.to_string(), orphans.len());
73    }
74    if !stale.is_empty() {
75        by_category.insert(categories::STALE.to_string(), stale.len());
76    }
77    if !unresolved_edges.is_empty() {
78        by_category.insert(
79            categories::UNRESOLVED_EDGE.to_string(),
80            unresolved_edges.len(),
81        );
82    }
83    for v in &violations {
84        let key = format!("{}{}", categories::VIOLATION_PREFIX, v.rule_id);
85        *by_category.entry(key).or_insert(0) += 1;
86    }
87
88    let total = orphans.len() + stale.len() + unresolved_edges.len() + violations.len();
89
90    IssueReport {
91        orphans,
92        stale,
93        unresolved_edges,
94        violations,
95        summary: IssueSummary { total, by_category },
96    }
97}
98
99/// Collect every edge whose target failed to resolve during build.
100pub fn find_unresolved_edges(graph: &Graph) -> Vec<UnresolvedEdge> {
101    let mut entries: Vec<UnresolvedEdge> = graph
102        .edges()
103        .iter()
104        .filter_map(|edge| unresolved_from(graph, edge))
105        .collect();
106
107    entries.sort_by(|a, b| {
108        a.source_id
109            .cmp(&b.source_id)
110            .then_with(|| a.relation.cmp(&b.relation))
111            .then_with(|| a.raw_target.cmp(&b.raw_target))
112    });
113
114    entries
115}
116
117fn unresolved_from(graph: &Graph, edge: &Edge) -> Option<UnresolvedEdge> {
118    let ResolvedTarget::Unresolved { raw, reason } = &edge.target else {
119        return None;
120    };
121    let source_path = graph
122        .nodes()
123        .get(&edge.source)
124        .map(|n| n.path.to_string_lossy().to_string())
125        .unwrap_or_default();
126    Some(UnresolvedEdge {
127        source_id: edge.source.clone(),
128        source_path,
129        relation: edge.relation.clone(),
130        raw_target: raw.clone(),
131        reason: reason.clone(),
132        location: edge.location.clone(),
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::model::{Confidence, Kind, Node, Status};
140    use indexmap::IndexMap;
141    use std::path::PathBuf;
142
143    fn node(id: &str) -> Node {
144        Node {
145            id: id.to_string(),
146            path: PathBuf::from(format!("{id}.md")),
147            title: id.to_string(),
148            kind: Kind::new("generic"),
149            status: Status::new("active"),
150            created: None,
151            updated: None,
152            reviewed: None,
153            owner: None,
154            supersedes: vec![],
155            superseded_by: None,
156            implements: vec![],
157            related: vec![],
158            tags: vec![],
159            orphan_ok: true, // skip orphan detection
160            attrs: Default::default(),
161        }
162    }
163
164    #[test]
165    fn finds_unresolved_edges() {
166        let mut map = IndexMap::new();
167        map.insert("a".into(), node("a"));
168        let edges = vec![Edge {
169            source: "a".to_string(),
170            target: ResolvedTarget::unresolved("missing.md", "path not in scope"),
171            relation: "references".to_string(),
172            confidence: Confidence::Extracted,
173            location: "L42".to_string(),
174        }];
175        let graph = Graph::new(map, edges);
176
177        let unresolved = find_unresolved_edges(&graph);
178        assert_eq!(unresolved.len(), 1);
179        assert_eq!(unresolved[0].source_id, "a");
180        assert_eq!(unresolved[0].raw_target, "missing.md");
181        assert_eq!(unresolved[0].reason, "path not in scope");
182    }
183
184    #[test]
185    fn empty_graph_has_no_issues() {
186        let graph = Graph::new(IndexMap::new(), vec![]);
187        let report = collect_issues(&graph, &Config::default());
188        assert_eq!(report.summary.total, 0);
189        assert!(report.summary.by_category.is_empty());
190    }
191
192    #[test]
193    fn summary_counts_are_additive() {
194        let mut map = IndexMap::new();
195        map.insert("a".into(), node("a"));
196        let edges = vec![
197            Edge {
198                source: "a".to_string(),
199                target: ResolvedTarget::unresolved("x.md", "not found"),
200                relation: "references".to_string(),
201                confidence: Confidence::Extracted,
202                location: "L1".to_string(),
203            },
204            Edge {
205                source: "a".to_string(),
206                target: ResolvedTarget::unresolved("y.md", "not found"),
207                relation: "references".to_string(),
208                confidence: Confidence::Extracted,
209                location: "L2".to_string(),
210            },
211        ];
212        let graph = Graph::new(map, edges);
213        let report = collect_issues(&graph, &Config::default());
214        assert_eq!(report.unresolved_edges.len(), 2);
215        assert_eq!(report.summary.by_category[categories::UNRESOLVED_EDGE], 2);
216    }
217}