agentstatedeveloper_core/
map.rs1use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use serde_json::json;
18
19use crate::error::Result;
20
21use crate::{
22 ASD_PATH_PREFIX, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, IndexStore,
23 LedgerEntry, LedgerKind, LedgerStore, RoleTag, Symbol,
24};
25
26pub fn run_map(
31 engine: &Engine,
32 agent_id: &str,
33 db_parent: Option<&Path>,
34 dry_run: bool,
35) -> Result<serde_json::Value> {
36 let index = AsgIndexStore::from_engine(engine);
37 let ledger = AsgLedgerStore::from_engine(engine);
38 let ref_name = engine.ref_name.clone();
39
40 let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
42 let tree = engine
43 .repo
44 .get_tree(&ref_name, &prefix)
45 .unwrap_or(serde_json::Value::Null);
46 let qnames: Vec<String> = match tree {
47 serde_json::Value::Object(m) => m.keys().cloned().collect(),
48 _ => Vec::new(),
49 };
50
51 let mut all_symbols: Vec<Symbol> = Vec::new();
52 for qn in qnames {
53 if let Ok(Some(sym)) = index.get_symbol_by_qname(&ref_name, &qn) {
54 all_symbols.push(sym);
55 }
56 }
57
58 let mut package_front_doors: BTreeMap<String, Symbol> = BTreeMap::new();
61 for sym in &all_symbols {
62 if !is_package_member(&sym.file) {
63 continue;
64 }
65 let pkg = package_dir(&sym.file);
66 package_front_doors
67 .entry(pkg)
68 .and_modify(|existing| {
69 if sym.qname.len() < existing.qname.len() {
70 *existing = sym.clone();
71 }
72 })
73 .or_insert_with(|| sym.clone());
74 }
75
76 let mut test_file_roles: BTreeMap<String, (Symbol, RoleTag)> = BTreeMap::new();
78 for sym in &all_symbols {
79 if let Some(role) = test_file_role(&sym.file) {
80 test_file_roles
81 .entry(sym.file.clone())
82 .and_modify(|(s, _)| {
83 if sym.qname.len() < s.qname.len() {
84 *s = sym.clone();
85 }
86 })
87 .or_insert_with(|| (sym.clone(), role));
88 }
89 }
90
91 let mut written_pkg = 0usize;
93 let mut written_test = 0usize;
94 let mut packages_out: Vec<serde_json::Value> = Vec::new();
95 let mut test_files_out: Vec<serde_json::Value> = Vec::new();
96 let author = Author {
97 kind: AuthorKind::Agent,
98 id: agent_id.to_string(),
99 };
100
101 let ctx_task_id = read_active_ctx_task_id(db_parent);
104
105 for (pkg, sym) in &package_front_doors {
106 packages_out.push(json!({
107 "package": pkg,
108 "front_door_qname": sym.qname,
109 "file": sym.file,
110 "role": RoleTag::PackageBoundary.as_str(),
111 }));
112 if !dry_run {
113 write_map_entry(
114 &ledger,
115 &ref_name,
116 sym,
117 RoleTag::PackageBoundary,
118 &format!("package boundary: {pkg}"),
119 &author,
120 ctx_task_id.as_deref(),
121 )?;
122 written_pkg += 1;
123 }
124 }
125
126 for (file, (sym, role)) in &test_file_roles {
127 test_files_out.push(json!({
128 "file": file,
129 "qname": sym.qname,
130 "role": role.as_str(),
131 }));
132 if !dry_run {
133 let summary = match role {
134 RoleTag::FastTest => format!("fast test: {file}"),
135 RoleTag::DiagnosticTest => format!("diagnostic test (env-gate before CI): {file}"),
136 _ => format!("test: {file}"),
137 };
138 write_map_entry(
139 &ledger,
140 &ref_name,
141 sym,
142 *role,
143 &summary,
144 &author,
145 ctx_task_id.as_deref(),
146 )?;
147 written_test += 1;
148 }
149 }
150
151 let pkg_set: BTreeSet<&String> = package_front_doors.keys().collect();
152
153 Ok(json!({
154 "intent": "asd-map",
155 "dry_run": dry_run,
156 "indexed_symbols": all_symbols.len(),
157 "packages": packages_out,
158 "test_files": test_files_out,
159 "summary": {
160 "package_count": pkg_set.len(),
161 "test_file_count": test_file_roles.len(),
162 "entries_written": written_pkg + written_test,
163 },
164 }))
165}
166
167fn write_map_entry(
168 ledger: &AsgLedgerStore,
169 ref_name: &str,
170 sym: &Symbol,
171 role: RoleTag,
172 summary: &str,
173 author: &Author,
174 ctx_task_id: Option<&str>,
175) -> Result<()> {
176 let mut entry = LedgerEntry::new(
177 &sym.symbol_id,
178 LedgerKind::Ownership,
179 summary,
180 author.clone(),
181 );
182 entry.entry_id = deterministic_entry_id(&sym.symbol_id, role.as_str());
185 entry.role = Some(role.as_str().to_string());
186 entry.tags.push("plan-c:asd-map".to_string());
187 if let Some(id) = ctx_task_id {
190 let tag = format!("ctx:task:{id}");
191 if !entry.tags.iter().any(|t| t == &tag) {
192 entry.tags.push(tag);
193 }
194 }
195 ledger.append_entry(ref_name, &entry, &author.id)?;
196 Ok(())
197}
198
199fn read_active_ctx_task_id(db_parent: Option<&Path>) -> Option<String> {
202 let raw = std::env::var("CTX_ACTIVE_TASK").ok().or_else(|| {
203 let p = db_parent?
204 .join(".asd")
205 .join("cache")
206 .join("active-task.json");
207 std::fs::read_to_string(p).ok()
208 })?;
209 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
210 v.get("task_id")?.as_str().map(String::from)
211}
212
213fn deterministic_entry_id(sym_id: &str, role: &str) -> String {
214 let key = format!("asd-map:{sym_id}:{role}");
215 let h = blake3::hash(key.as_bytes()).to_hex();
216 let short: String = h.chars().take(24).collect();
217 format!("led_map_{short}")
218}
219
220fn is_package_member(file: &str) -> bool {
223 file.contains('/')
225}
226
227fn package_dir(file: &str) -> String {
230 match file.rsplit_once('/') {
231 Some((d, _)) => d.to_string(),
232 None => String::new(),
233 }
234}
235
236const DIAGNOSTIC_BODY_MARKERS: &[&str] = &[
240 "FileManager.default.fileExists",
241 "FileManager.fileExists",
242 "renderFullSong",
243 ".trace(",
244 "batchRender",
245 "durationSeconds",
246 "/Users/", "tmp_path", "tempfile.NamedTemporaryFile",
249 "subprocess.run",
250];
251
252fn body_looks_diagnostic(file: &str) -> bool {
255 let path = Path::new(file);
256 let bytes = match std::fs::read(path) {
257 Ok(b) => b,
258 Err(_) => return false,
259 };
260 let limit = bytes.len().min(64 * 1024);
261 let head = match std::str::from_utf8(&bytes[..limit]) {
262 Ok(s) => s,
263 Err(_) => return false,
264 };
265 DIAGNOSTIC_BODY_MARKERS.iter().any(|m| head.contains(m))
266}
267
268fn test_file_role(file: &str) -> Option<RoleTag> {
271 let lower = file.to_lowercase();
272 let is_test = lower.contains("/test")
273 || lower.contains("_test.")
274 || lower.contains(".test.")
275 || lower.contains("/spec/")
276 || lower.ends_with("_spec.rb");
277 if !is_test {
278 return None;
279 }
280 let diagnostic_by_name = lower.contains("diagnostic")
281 || lower.contains("integration")
282 || lower.contains("e2e")
283 || lower.contains("real_file")
284 || lower.contains("slow");
285 let diagnostic = diagnostic_by_name || body_looks_diagnostic(file);
286 Some(if diagnostic {
287 RoleTag::DiagnosticTest
288 } else {
289 RoleTag::FastTest
290 })
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_file_role_classifies_filenames() {
299 assert_eq!(
300 test_file_role("tests/fast_test.py"),
301 Some(RoleTag::FastTest)
302 );
303 assert_eq!(
304 test_file_role("tests/diagnostic_real_file_test.py"),
305 Some(RoleTag::DiagnosticTest)
306 );
307 assert_eq!(
308 test_file_role("tests/integration_test.py"),
309 Some(RoleTag::DiagnosticTest)
310 );
311 assert_eq!(test_file_role("src/pkg/module.py"), None);
312 }
313
314 #[test]
315 fn body_looks_diagnostic_matches_session_drift_markers() {
316 let tmp = tempfile::tempdir().unwrap();
317 let path = tmp.path().join("foo_test.py");
318 std::fs::write(
319 &path,
320 "import subprocess\nsubprocess.run(['swift', 'test'])\n",
321 )
322 .unwrap();
323 assert!(body_looks_diagnostic(path.to_str().unwrap()));
324 }
325
326 #[test]
327 fn body_looks_diagnostic_false_for_clean_unit_test() {
328 let tmp = tempfile::tempdir().unwrap();
329 let path = tmp.path().join("foo_test.py");
330 std::fs::write(&path, "def test_addition():\n assert 1 + 1 == 2\n").unwrap();
331 assert!(!body_looks_diagnostic(path.to_str().unwrap()));
332 }
333
334 #[test]
335 fn body_looks_diagnostic_false_for_missing_file() {
336 assert!(!body_looks_diagnostic(
337 "/nonexistent/path/that/cannot/exist.py"
338 ));
339 }
340
341 #[test]
342 fn read_active_ctx_task_id_extracts_from_file() {
343 let tmp = tempfile::tempdir().unwrap();
344 let cache_dir = tmp.path().join(".asd/cache");
345 std::fs::create_dir_all(&cache_dir).unwrap();
346 std::fs::write(
347 cache_dir.join("active-task.json"),
348 r#"{"task_id":"t-007","scope":["x/**"]}"#,
349 )
350 .unwrap();
351 assert_eq!(
352 read_active_ctx_task_id(Some(tmp.path())).as_deref(),
353 Some("t-007")
354 );
355 }
356
357 #[test]
358 fn read_active_ctx_task_id_returns_none_when_no_source() {
359 let tmp = tempfile::tempdir().unwrap();
360 assert_eq!(read_active_ctx_task_id(Some(tmp.path())), None);
361 }
362
363 #[test]
364 fn test_file_role_promotes_clean_test_to_diagnostic_on_body_marker() {
365 let tmp = tempfile::tempdir().unwrap();
366 let path = tmp.path().join("fast_test.py");
367 std::fs::write(
368 &path,
369 "import subprocess\ndef test_ok():\n subprocess.run(['ls'])\n",
370 )
371 .unwrap();
372 assert_eq!(
373 test_file_role(path.to_str().unwrap()),
374 Some(RoleTag::DiagnosticTest)
375 );
376 }
377
378 #[test]
379 fn deterministic_entry_id_is_stable() {
380 let a = deterministic_entry_id("sym_x", "fast-test");
381 let b = deterministic_entry_id("sym_x", "fast-test");
382 assert_eq!(a, b);
383 assert!(a.starts_with("led_map_"));
384 }
385
386 #[test]
387 fn deterministic_entry_id_changes_with_role() {
388 let a = deterministic_entry_id("sym_x", "fast-test");
389 let b = deterministic_entry_id("sym_x", "diagnostic-test");
390 assert_ne!(a, b);
391 }
392
393 #[test]
394 fn package_dir_returns_parent_directory() {
395 assert_eq!(package_dir("src/pkg/mod.py"), "src/pkg");
396 assert_eq!(package_dir("root.py"), "");
397 }
398}