agentstatedeveloper_core/
overview.rs1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9
10use serde_json::{Value, json};
11
12use crate::{
13 Direction, Engine, Symbol, SymbolKind, classify_layer_sym, detect_communities,
14 endpoints_from_tree, load_layer_overrides, match_edges, resolve_repo_id, symbol_tier,
15};
16
17pub fn build_id_map(engine: &Engine) -> HashMap<String, Symbol> {
21 if let Some(fts) = engine.fts.as_ref() {
23 if fts.symbols_cached_for(&engine.ref_name) {
24 let map = fts.build_id_map_cached(&engine.ref_name);
25 if !map.is_empty() {
26 return map;
27 }
28 }
29 }
30 let tree = engine
32 .repo
33 .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
34 .unwrap_or(Value::Object(Default::default()));
35 tree.as_object()
36 .map(|m| {
37 m.values()
38 .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
39 .map(|s| (s.symbol_id.clone(), s))
40 .collect()
41 })
42 .unwrap_or_default()
43}
44
45pub fn architecture_overview(engine: &Engine, top: usize) -> Value {
51 let id_map = build_id_map(engine);
52 let db_path = engine.db_path.clone().unwrap_or_default();
53 let overrides = load_layer_overrides(&db_path);
54
55 let mut languages: HashMap<String, usize> = HashMap::new();
57 let mut packages: HashMap<String, usize> = HashMap::new();
58 let mut layers: BTreeMap<&'static str, usize> = BTreeMap::new();
59 for s in id_map.values() {
60 *languages.entry(s.language.clone()).or_default() += 1;
61 *packages.entry(package_of(&s.file)).or_default() += 1;
62 let layer = classify_layer_sym(&s.file, &s.qname, symbol_tier(&s.file), &overrides);
63 *layers.entry(layer).or_default() += 1;
64 }
65
66 let degree = symbol_degree(engine);
68 let mut hot: Vec<(&String, usize)> = degree.iter().map(|(k, v)| (k, *v)).collect();
69 hot.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
70 let hotspots: Vec<Value> = hot
71 .iter()
72 .take(top)
73 .filter_map(|(id, deg)| {
74 id_map
75 .get(*id)
76 .map(|s| json!({ "qname": s.qname, "file": s.file, "degree": deg }))
77 })
78 .collect();
79
80 let ep_tree = engine
82 .repo
83 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
84 .unwrap_or(Value::Null);
85 let endpoints = endpoints_from_tree(&ep_tree);
86 let mut inbound: Vec<Value> = endpoints
87 .iter()
88 .filter(|e| e.direction == Direction::Inbound)
89 .map(|e| json!({ "contract": e.contract, "qname": e.qname }))
90 .collect();
91 inbound.sort_by(|a, b| a["contract"].as_str().cmp(&b["contract"].as_str()));
92 let outbound_count = endpoints
93 .iter()
94 .filter(|e| e.direction == Direction::Outbound)
95 .count();
96
97 let clusters = compute_clusters(engine, &id_map, °ree, top);
100
101 let repo_id = resolve_repo_id(std::env::var("ASD_REPO_ID").ok().as_deref(), &cwd());
102
103 json!({
104 "repo_id": repo_id,
105 "symbols": id_map.len(),
106 "languages": sorted_counts(languages, usize::MAX),
107 "packages": sorted_counts(packages, top),
108 "layers": layers.iter().map(|(k, v)| json!({ "layer": k, "symbols": v })).collect::<Vec<_>>(),
109 "clusters": clusters,
110 "routes": { "inbound": inbound, "outbound_consumers": outbound_count },
111 "hotspots": hotspots,
112 "note": "clusters are call-graph communities (functional groups that call each other); layers are the orthogonal path-based view",
113 })
114}
115
116fn package_of(file: &str) -> String {
118 match file.rfind('/') {
119 Some(i) => file[..i].to_string(),
120 None => ".".to_string(),
121 }
122}
123
124fn symbol_degree(engine: &Engine) -> HashMap<String, usize> {
126 let mut degree: HashMap<String, usize> = HashMap::new();
127 for (path, key) in [
128 ("/asd/v1/index/callees", "callees"),
129 ("/asd/v1/index/callers", "callers"),
130 ] {
131 let tree = engine
132 .repo
133 .get_tree(&engine.ref_name, path)
134 .unwrap_or(Value::Null);
135 if let Some(obj) = tree.as_object() {
136 for (sym_id, v) in obj {
137 let n = v.get(key).and_then(|x| x.as_array()).map_or(0, |a| a.len());
138 *degree.entry(sym_id.clone()).or_default() += n;
139 }
140 }
141 }
142 degree
143}
144
145fn compute_clusters(
148 engine: &Engine,
149 id_map: &HashMap<String, Symbol>,
150 degree: &HashMap<String, usize>,
151 top: usize,
152) -> Vec<Value> {
153 let tree = engine
154 .repo
155 .get_tree(&engine.ref_name, "/asd/v1/index/callees")
156 .unwrap_or(Value::Null);
157 let mut edges: Vec<(String, String)> = Vec::new();
158 let mut node_set: BTreeSet<String> = BTreeSet::new();
159 if let Some(obj) = tree.as_object() {
160 for (sym, v) in obj {
161 if let Some(arr) = v.get("callees").and_then(|a| a.as_array()) {
162 for c in arr.iter().filter_map(|c| c.as_str()) {
163 node_set.insert(sym.clone());
164 node_set.insert(c.to_string());
165 edges.push((sym.clone(), c.to_string()));
166 }
167 }
168 }
169 }
170 if edges.is_empty() {
171 return Vec::new();
172 }
173 let nodes: Vec<String> = node_set.into_iter().collect();
174 let comm = detect_communities(&nodes, &edges);
175
176 let mut groups: HashMap<usize, Vec<&String>> = HashMap::new();
177 for (sym, c) in &comm {
178 groups.entry(*c).or_default().push(sym);
179 }
180
181 let mut clusters: Vec<(usize, Value)> = groups
182 .into_values()
183 .filter(|members| members.len() >= 2)
184 .map(|members| {
185 let mut pkg: HashMap<String, usize> = HashMap::new();
187 for m in &members {
188 if let Some(s) = id_map.get(*m) {
189 *pkg.entry(package_of(&s.file)).or_default() += 1;
190 }
191 }
192 let package = pkg
193 .into_iter()
194 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
195 .map(|(p, _)| p)
196 .unwrap_or_default();
197 let representative = members
199 .iter()
200 .filter_map(|m| {
201 id_map.get(*m).map(|s| (degree.get(*m).copied().unwrap_or(0), s.qname.clone()))
202 })
203 .max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)))
204 .map(|(_, q)| q)
205 .unwrap_or_default();
206 (
207 members.len(),
208 json!({ "size": members.len(), "package": package, "representative": representative }),
209 )
210 })
211 .collect();
212 clusters.sort_by(|a, b| b.0.cmp(&a.0));
213 clusters.into_iter().take(top).map(|(_, v)| v).collect()
214}
215
216fn sorted_counts(map: HashMap<String, usize>, limit: usize) -> Vec<Value> {
218 let mut v: Vec<(String, usize)> = map.into_iter().collect();
219 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
220 v.into_iter()
221 .take(limit)
222 .map(|(name, n)| json!({ "name": name, "symbols": n }))
223 .collect()
224}
225
226fn cwd() -> std::path::PathBuf {
227 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
228}
229
230pub fn dead_code_report(engine: &Engine, limit: usize, include_tests: bool) -> Value {
236 let id_map = build_id_map(engine);
237
238 let callers_tree = engine
240 .repo
241 .get_tree(&engine.ref_name, "/asd/v1/index/callers")
242 .unwrap_or(Value::Null);
243 let mut has_callers: HashSet<String> = HashSet::new();
244 if let Some(obj) = callers_tree.as_object() {
245 for (sym_id, v) in obj {
246 let n = v
247 .get("callers")
248 .and_then(|a| a.as_array())
249 .map_or(0, |a| a.len());
250 if n > 0 {
251 has_callers.insert(sym_id.clone());
252 }
253 }
254 }
255
256 let ep_tree = engine
258 .repo
259 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
260 .unwrap_or(Value::Null);
261 let handler_syms: HashSet<String> = endpoints_from_tree(&ep_tree)
262 .into_iter()
263 .filter(|e| e.direction == Direction::Inbound)
264 .map(|e| e.symbol_id)
265 .collect();
266
267 let mut excluded_handlers = 0usize;
268 let mut excluded_tests = 0usize;
269 let mut dead: Vec<&Symbol> = Vec::new();
270 for (sym_id, sym) in &id_map {
271 if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
272 continue;
273 }
274 if has_callers.contains(sym_id) {
275 continue;
276 }
277 if is_runtime_entry(&sym.qname) {
278 continue;
279 }
280 if handler_syms.contains(sym_id) {
281 excluded_handlers += 1;
282 continue;
283 }
284 if !include_tests && is_test_symbol(&sym.file, &sym.qname) {
285 excluded_tests += 1;
286 continue;
287 }
288 dead.push(sym);
289 }
290 dead.sort_by(|a, b| {
291 a.file
292 .cmp(&b.file)
293 .then_with(|| a.start.line.cmp(&b.start.line))
294 });
295
296 let total = dead.len();
297 let candidates: Vec<Value> = dead
298 .iter()
299 .take(limit)
300 .map(|s| {
301 json!({
302 "qname": s.qname,
303 "file": s.file,
304 "line": s.start.line,
305 "kind": dc_kind_str(s.kind),
306 })
307 })
308 .collect();
309
310 let note = "Functions/methods with no inbound call edges in the index. NOT definitive — \
311 the static call graph misses public API used by other repos, dynamic dispatch \
312 (reflection, callbacks, trait/interface impls), framework-invoked methods, and \
313 calls the resolver couldn't bind (see `dropped_call_edges` in `asd index`). \
314 Route handlers, test functions, and main/dunder methods are excluded.";
315
316 json!({
317 "count": total,
318 "shown": candidates.len(),
319 "excluded": { "route_handlers": excluded_handlers, "test_symbols": excluded_tests },
320 "note": note,
321 "candidates": candidates,
322 })
323}
324
325fn is_runtime_entry(qname: &str) -> bool {
328 let last = qname.rsplit(['.', ':']).next().unwrap_or(qname);
329 last == "main" || (last.starts_with("__") && last.ends_with("__"))
330}
331
332fn is_test_symbol(file: &str, qname: &str) -> bool {
334 symbol_tier(file) == 2 || qname.split(['.', ':']).any(|c| c == "tests" || c == "test")
335}
336
337fn dc_kind_str(k: SymbolKind) -> &'static str {
338 match k {
339 SymbolKind::Function => "function",
340 SymbolKind::Method => "method",
341 SymbolKind::Class => "class",
342 SymbolKind::Module => "module",
343 SymbolKind::Variable => "variable",
344 }
345}
346
347pub fn endpoints_report(engine: &Engine) -> Value {
352 let tree = engine
353 .repo
354 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
355 .unwrap_or(Value::Null);
356 let mut endpoints = endpoints_from_tree(&tree);
357 endpoints.sort_by(|a, b| a.contract.cmp(&b.contract).then(a.qname.cmp(&b.qname)));
358 let edges = match_edges(&endpoints);
359 json!({ "endpoints": endpoints, "edges": edges })
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn package_of_uses_parent_dir() {
368 assert_eq!(package_of("crates/core/src/lib.rs"), "crates/core/src");
369 assert_eq!(package_of("main.rs"), ".");
370 }
371
372 #[test]
373 fn sorted_counts_orders_desc_then_name_and_truncates() {
374 let mut m = HashMap::new();
375 m.insert("a".to_string(), 1);
376 m.insert("b".to_string(), 3);
377 m.insert("c".to_string(), 3);
378 let v = sorted_counts(m, 2);
379 assert_eq!(v.len(), 2);
380 assert_eq!(v[0]["name"], "b");
381 assert_eq!(v[0]["symbols"], 3);
382 assert_eq!(v[1]["name"], "c");
383 }
384
385 #[test]
386 fn runtime_entries_excluded() {
387 assert!(is_runtime_entry("app.main"));
388 assert!(is_runtime_entry("Foo.__init__"));
389 assert!(is_runtime_entry("mod::__repr__"));
390 assert!(!is_runtime_entry("app.helper"));
391 assert!(!is_runtime_entry("Foo.compute"));
392 assert!(!is_runtime_entry("app.do_thing"));
393 }
394
395 #[test]
396 fn test_symbols_detected_by_path_and_qname() {
397 assert!(is_test_symbol(
398 "crates/x/src/lib.rs",
399 "x.lib.tests.it_works"
400 ));
401 assert!(is_test_symbol(
402 "crates/x/src/lib.rs",
403 "x::lib::tests::it_works"
404 ));
405 assert!(is_test_symbol("crates/x/tests/foo.rs", "x.foo.bar"));
406 assert!(!is_test_symbol("crates/x/src/lib.rs", "x.lib.compute"));
407 assert!(!is_test_symbol("src/lib.rs", "app.latest_value"));
408 }
409}