Skip to main content

perspt_agent/
exploration.rs

1//! Deterministic, read-only repository orientation for the PSP-9 runtime.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::io::Read;
5use std::path::Path;
6
7use anyhow::Result;
8use ignore::WalkBuilder;
9use perspt_sdk::ProjectMap;
10use serde::{Deserialize, Serialize};
11
12/// The deterministic repository map plus its provenance witnesses — the
13/// seed of `SearchContext` (PSP-10: `ProjectMap` re-homed into
14/// `perspt_sdk::search`; the removed `ExplorationReport` surface collapses
15/// to exactly what the runtime consumes).
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct WorkspaceMap {
18    pub project_map: ProjectMap,
19    /// Content hashes of the observed inputs, for provenance.
20    pub input_witnesses: Vec<String>,
21}
22
23const MAX_PACKAGE_ROOTS: usize = 512;
24const MAX_ENTRY_POINTS: usize = 64;
25const MAX_RISK_HOTSPOTS: usize = 64;
26const MAX_MANIFEST_WITNESSES: usize = 512;
27
28/// Build a bounded project map from filesystem metadata and streamed
29/// manifest hashes. This phase has no mutation capability and is safe to
30/// run in parallel with other read-only preparation.
31pub fn map_workspace(root: &Path) -> Result<WorkspaceMap> {
32    let mut scan = WorkspaceScan::default();
33    let mut walker = WalkBuilder::new(root);
34    let walk_root = root.to_path_buf();
35    walker
36        .hidden(false)
37        .git_ignore(true)
38        .git_global(true)
39        .git_exclude(true)
40        .max_depth(Some(12))
41        .filter_entry(move |entry| include_entry(&walk_root, entry.path()));
42    for entry in walker
43        .build()
44        .flatten()
45        .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
46    {
47        scan.observe(root, entry.path());
48    }
49
50    let mut languages: Vec<String> = scan
51        .language_counts
52        .into_iter()
53        .map(|(language, count)| format!("{language}:{count}"))
54        .collect();
55    languages.sort();
56    let map = ProjectMap {
57        languages,
58        package_roots: scan.package_roots.into_iter().collect(),
59        build_systems: scan.build_systems.into_iter().collect(),
60        entry_points: scan.entry_points.into_iter().collect(),
61        risk_hotspots: scan.risk_hotspots.into_iter().collect(),
62    };
63    Ok(WorkspaceMap {
64        project_map: map,
65        input_witnesses: scan
66            .witnesses
67            .into_iter()
68            .map(|(path, hash)| format!("{path}:{hash}"))
69            .collect(),
70    })
71}
72
73fn include_entry(root: &Path, path: &Path) -> bool {
74    if path == root {
75        return true;
76    }
77    let relative = path.strip_prefix(root).unwrap_or(path);
78    !relative.components().any(|component| {
79        matches!(
80            component.as_os_str().to_str(),
81            Some(
82                ".git"
83                    | ".perspt"
84                    | ".venv"
85                    | ".pytest_cache"
86                    | ".ruff_cache"
87                    | ".mypy_cache"
88                    | "__pycache__"
89                    | "node_modules"
90                    | "target"
91            )
92        )
93    })
94}
95
96#[derive(Default)]
97struct WorkspaceScan {
98    language_counts: BTreeMap<&'static str, usize>,
99    package_roots: BTreeSet<String>,
100    build_systems: BTreeSet<String>,
101    entry_points: BTreeSet<String>,
102    risk_hotspots: BTreeSet<String>,
103    witnesses: BTreeMap<String, String>,
104}
105
106impl WorkspaceScan {
107    fn observe(&mut self, root: &Path, path: &Path) {
108        let relative = path.strip_prefix(root).unwrap_or(path);
109        let rendered = relative.to_string_lossy().replace('\\', "/");
110        if let Some(language) = language_for(path) {
111            *self.language_counts.entry(language).or_default() += 1;
112        }
113        if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
114            if let Some(system) = build_system(name) {
115                self.build_systems.insert(system.to_string());
116                bounded_insert(
117                    &mut self.package_roots,
118                    relative
119                        .parent()
120                        .unwrap_or_else(|| Path::new("."))
121                        .to_string_lossy()
122                        .replace('\\', "/"),
123                    MAX_PACKAGE_ROOTS,
124                );
125                if let Ok(hash) = hash_file(path) {
126                    bounded_insert_map(
127                        &mut self.witnesses,
128                        rendered.clone(),
129                        hash,
130                        MAX_MANIFEST_WITNESSES,
131                    );
132                }
133            }
134            if matches!(
135                name,
136                "main.rs" | "main.py" | "main.ts" | "main.js" | "lib.rs"
137            ) || name.starts_with("app.")
138            {
139                bounded_insert(&mut self.entry_points, rendered.clone(), MAX_ENTRY_POINTS);
140            }
141        }
142        if rendered.contains("migrations/")
143            || rendered.contains(".github/workflows/")
144            || rendered.contains("auth")
145            || rendered.contains("security")
146            || rendered.ends_with("Cargo.lock")
147            || rendered.ends_with("package-lock.json")
148        {
149            bounded_insert(&mut self.risk_hotspots, rendered, MAX_RISK_HOTSPOTS);
150        }
151    }
152}
153
154/// Retain the lexicographically smallest `cap` values independent of
155/// traversal order. The whole repository is observed, while summary memory
156/// remains bounded and deterministic.
157fn bounded_insert(values: &mut BTreeSet<String>, value: String, cap: usize) {
158    values.insert(value);
159    if values.len() > cap {
160        values.pop_last();
161    }
162}
163
164fn bounded_insert_map(
165    values: &mut BTreeMap<String, String>,
166    key: String,
167    value: String,
168    cap: usize,
169) {
170    values.insert(key, value);
171    if values.len() > cap {
172        values.pop_last();
173    }
174}
175
176/// SHA-256 a manifest as a stream. Repository orientation never needs to
177/// materialize even an accidentally enormous manifest in memory.
178fn hash_file(path: &Path) -> std::io::Result<String> {
179    use sha2::Digest;
180    let mut file = std::fs::File::open(path)?;
181    let mut hasher = sha2::Sha256::new();
182    let mut buffer = [0u8; 64 * 1024];
183    loop {
184        let read = file.read(&mut buffer)?;
185        if read == 0 {
186            break;
187        }
188        hasher.update(&buffer[..read]);
189    }
190    let digest = hasher.finalize();
191    let mut hex = String::with_capacity(digest.len() * 2);
192    for byte in digest {
193        use std::fmt::Write as _;
194        write!(&mut hex, "{byte:02x}").expect("writing into a string cannot fail");
195    }
196    Ok(hex)
197}
198
199fn language_for(path: &Path) -> Option<&'static str> {
200    match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
201        "rs" => Some("rust"),
202        "py" => Some("python"),
203        "ts" | "tsx" => Some("typescript"),
204        "js" | "jsx" => Some("javascript"),
205        "go" => Some("go"),
206        "java" => Some("java"),
207        "kt" | "kts" => Some("kotlin"),
208        "c" | "h" => Some("c"),
209        "cc" | "cpp" | "hpp" => Some("cpp"),
210        "zig" => Some("zig"),
211        "rb" => Some("ruby"),
212        _ => None,
213    }
214}
215
216fn build_system(name: &str) -> Option<&'static str> {
217    match name {
218        "Cargo.toml" => Some("cargo"),
219        "pyproject.toml" => Some("python-pyproject"),
220        "package.json" => Some("node"),
221        "go.mod" => Some("go-modules"),
222        "pom.xml" => Some("maven"),
223        "build.gradle" | "build.gradle.kts" => Some("gradle"),
224        "build.zig" => Some("zig-build"),
225        "Makefile" => Some("make"),
226        _ => None,
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn project_map_is_deterministic_and_manifest_backed() {
236        let root = tempfile::tempdir().unwrap();
237        std::fs::create_dir_all(root.path().join("src")).unwrap();
238        std::fs::write(root.path().join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
239        std::fs::write(root.path().join("src/main.rs"), "fn main() {}\n").unwrap();
240        let left = map_workspace(root.path()).unwrap();
241        let right = map_workspace(root.path()).unwrap();
242        assert_eq!(left.project_map, right.project_map);
243        assert_eq!(left.input_witnesses, right.input_witnesses);
244        assert_eq!(left.project_map.build_systems, ["cargo"]);
245    }
246
247    #[test]
248    fn project_map_excludes_installed_environments_and_build_outputs() {
249        let root = tempfile::tempdir().unwrap();
250        std::fs::create_dir_all(root.path().join("src")).unwrap();
251        std::fs::create_dir_all(root.path().join(".venv/lib/site-packages/pkg")).unwrap();
252        std::fs::create_dir_all(root.path().join("target/generated")).unwrap();
253        std::fs::write(root.path().join("pyproject.toml"), "[project]\nname='x'\n").unwrap();
254        std::fs::write(root.path().join("src/main.py"), "print('x')\n").unwrap();
255        std::fs::write(
256            root.path().join(".venv/lib/site-packages/pkg/main.py"),
257            "installed = True\n",
258        )
259        .unwrap();
260        std::fs::write(
261            root.path().join("target/generated/main.py"),
262            "generated = True\n",
263        )
264        .unwrap();
265
266        let report = map_workspace(root.path()).unwrap();
267        assert_eq!(report.project_map.languages, ["python:1"]);
268        assert_eq!(report.project_map.entry_points, ["src/main.py"]);
269    }
270
271    #[test]
272    fn bounded_summaries_are_independent_of_traversal_order() {
273        let values: Vec<String> = (0..600).map(|index| format!("crate-{index:04}")).collect();
274        let mut forward = BTreeSet::new();
275        let mut reverse = BTreeSet::new();
276        for value in &values {
277            bounded_insert(&mut forward, value.clone(), MAX_PACKAGE_ROOTS);
278        }
279        for value in values.iter().rev() {
280            bounded_insert(&mut reverse, value.clone(), MAX_PACKAGE_ROOTS);
281        }
282        assert_eq!(forward, reverse);
283        assert_eq!(forward.len(), MAX_PACKAGE_ROOTS);
284        assert!(forward.contains("crate-0000"));
285        assert!(!forward.contains("crate-0599"));
286    }
287
288    #[test]
289    fn streamed_manifest_witness_matches_the_content_hash() {
290        let root = tempfile::tempdir().unwrap();
291        let mut manifest = b"[package]\nname='large'\n#".to_vec();
292        manifest.extend(std::iter::repeat_n(b'x', 200_000));
293        let path = root.path().join("Cargo.toml");
294        std::fs::write(&path, &manifest).unwrap();
295        assert_eq!(
296            hash_file(&path).unwrap(),
297            perspt_sdk::ledger::content_hash(&manifest)
298        );
299    }
300}