Skip to main content

agentstatedeveloper_core/
map.rs

1//! `asd map` core — initial-read project summary.
2//!
3//! Walks the indexed project, identifies package boundaries from directory
4//! structure, and classifies test files into `fast-test` vs `diagnostic-test`
5//! per file-name and body heuristics. Results land as `Ownership` ledger
6//! entries so the next session inherits the project mental model without
7//! re-deriving it. Idempotent: entry IDs are deterministic hashes of
8//! (symbol_id + role), so re-running overwrites prior tags.
9//!
10//! Extracted from the CLI so both `asd map` and the `map` MCP tool share one
11//! implementation: [`run_map`] returns the summary payload and (unless
12//! `dry_run`) writes the ledger entries.
13
14use 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
26/// Build the initial-read project map and (unless `dry_run`) write the
27/// Ownership ledger entries. Returns the JSON summary payload. `agent_id`
28/// attributes the writes; `db_parent` is used to pick up the active CTX task
29/// id for provenance tagging.
30pub 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    // Collect every indexed symbol.
41    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    // Group by package — the directory of the file. For each package, pick a
59    // "front-door" symbol (the one with the shortest qname).
60    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    // Classify test files (one symbol per file is enough for the role tag).
77    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    // Build the summary payload + write entries (unless dry-run).
92    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    // Pick up the active CTX task id once, pass to every write so the
102    // provenance tag lands consistently.
103    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    // Deterministic entry id so re-running `asd map` overwrites instead of
183    // appending duplicates.
184    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    // Auto-tag with the active CTX task id so future scope-by-task filtering /
188    // audits can attribute the entries to the gesture that wrote them.
189    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
199/// Read the active CTX task id from `CTX_ACTIVE_TASK` env var (JSON:
200/// `{"task_id": "..."}`) with a fallback to `.asd/cache/active-task.json`.
201fn 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
220/// True when this file lives inside a recognizable language package. Used to
221/// scope which files seed a package-boundary tag.
222fn is_package_member(file: &str) -> bool {
223    // Conservative: any file with at least one path separator (root files skip).
224    file.contains('/')
225}
226
227/// Directory portion of `file`, used as the package key. Keeps the leaf
228/// directory (not the package NAME) because that round-trips to file globs.
229fn package_dir(file: &str) -> String {
230    match file.rsplit_once('/') {
231        Some((d, _)) => d.to_string(),
232        None => String::new(),
233    }
234}
235
236/// Body markers that flip a test file from `fast-test` → `diagnostic-test`
237/// when present: tests that touch the real filesystem, render full songs,
238/// batch-run, or take long are diagnostic by nature.
239const DIAGNOSTIC_BODY_MARKERS: &[&str] = &[
240    "FileManager.default.fileExists",
241    "FileManager.fileExists",
242    "renderFullSong",
243    ".trace(",
244    "batchRender",
245    "durationSeconds",
246    "/Users/",  // hard-coded paths to user dirs
247    "tmp_path", // pytest real-fs fixtures
248    "tempfile.NamedTemporaryFile",
249    "subprocess.run",
250];
251
252/// Read the file's first ~64 KiB and check whether any diagnostic-body marker
253/// appears. Errors and missing files return false (body-sniff is opt-in).
254fn 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
268/// Classify a file as a test file and return its role tag, or None if the file
269/// isn't a test. Combines filename heuristics with optional body-sniff.
270fn 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}