Skip to main content

mati_core/analysis/
mod.rs

1// Layer 0 — static analysis engine (M-06)
2// Parallel file walker (ignore + rayon), tree-sitter parsing,
3// git2 history mining, dependency parsing (Cargo.toml, package.json, go.mod)
4// Target: <200ms on a 250-file Rust project
5
6use std::collections::HashSet;
7
8use crate::store::record::FileRecord;
9
10pub mod auto_memory;
11pub mod blast_radius;
12pub mod claude_md;
13pub mod clusters;
14pub mod deps;
15pub mod edges;
16pub mod enrich_signals;
17pub mod git;
18pub mod onboarding;
19pub mod parser;
20pub mod propagation;
21pub mod reparse;
22pub mod resolvers;
23pub mod walker;
24
25pub use auto_memory::{auto_memory_dir, import_auto_memory, AutoMemoryImport};
26pub use claude_md::{import_claude_md, ClaudeMdImport};
27pub use deps::{
28    dep_display_name_from_key, dep_record_key, parse_dep_key, parse_dependencies, DepEcosystem,
29    DepEntry, DepSignals, DepVersion, ManifestKind,
30};
31pub use edges::{build_edges, build_edges_with_root, Layer0Edges};
32pub use git::{mine_git_history, GitSignals};
33pub use parser::{hash_and_parse_parallel, parse_file, parse_files_parallel, StaticFileAnalysis};
34pub use walker::{Language, WalkedFile, Walker};
35
36pub(crate) fn public_api_symbols(analysis: &StaticFileAnalysis) -> Vec<String> {
37    let mut seen = HashSet::new();
38    let mut symbols =
39        Vec::with_capacity(analysis.entry_points.len() + analysis.exported_types.len());
40
41    for symbol in analysis
42        .entry_points
43        .iter()
44        .chain(analysis.exported_types.iter())
45    {
46        if seen.insert(symbol.as_str()) {
47            symbols.push(symbol.clone());
48        }
49    }
50
51    symbols
52}
53
54/// Build one `FileRecord` from the parsed Layer 0 signals for a file.
55///
56/// If the parser extracted a module-level doc comment (`analysis.module_doc`),
57/// it is used as the initial `purpose`. Records with a purpose are auto-promoted
58/// to `additionalContext` quality in the caller (`init.rs`).
59pub fn build_file_record(
60    file: &WalkedFile,
61    analysis: &StaticFileAnalysis,
62    git: Option<&GitSignals>,
63    hotspot_files: Option<&HashSet<String>>,
64    last_modified_session: u64,
65) -> FileRecord {
66    let path = file.rel_path.clone();
67    let (change_frequency, last_author, is_hotspot) = match git {
68        Some(signals) => (
69            signals.change_frequency.get(&path).copied().unwrap_or(0),
70            signals.last_authors.get(&path).cloned(),
71            hotspot_files
72                .map(|hotspots| hotspots.contains(&path))
73                .unwrap_or(false),
74        ),
75        None => (0, None, false),
76    };
77
78    let token_cost_estimate = (file.size_bytes / 4).min(u32::MAX as u64) as u32;
79    let public_api = public_api_symbols(analysis);
80
81    let mut fr = FileRecord::layer0_stub(
82        path,
83        public_api,
84        analysis.imports.iter().map(|i| i.path.clone()).collect(),
85        analysis.todos.clone(),
86        analysis.unsafe_count,
87        analysis.unwrap_count,
88        change_frequency,
89        last_author,
90        is_hotspot,
91        token_cost_estimate,
92        last_modified_session,
93    );
94
95    // Propagate author-written doc comment to purpose — gives immediate
96    // additionalContext value after `mati init` with no LLM calls.
97    if let Some(doc) = &analysis.module_doc {
98        fr.purpose = doc.clone();
99    }
100
101    fr.content_hash = analysis.content_hash.clone();
102    fr.line_count = analysis.line_count;
103
104    fr
105}
106
107/// Build layer-0 file records for a batch of parsed files.
108pub fn build_file_records(
109    files: &[WalkedFile],
110    analyses: &[StaticFileAnalysis],
111    git: Option<&GitSignals>,
112    last_modified_session: u64,
113) -> Vec<FileRecord> {
114    assert_eq!(
115        files.len(),
116        analyses.len(),
117        "build_file_records expects one analysis per walked file"
118    );
119
120    let hotspot_files = git.map(|signals| {
121        signals
122            .hotspot_files
123            .iter()
124            .cloned()
125            .collect::<HashSet<_>>()
126    });
127    let hotspot_files = hotspot_files.as_ref();
128
129    files
130        .iter()
131        .zip(analyses)
132        .map(|(file, analysis)| {
133            build_file_record(file, analysis, git, hotspot_files, last_modified_session)
134        })
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::analysis::parser::{ImportKind, ImportStatement};
142    use crate::store::record::TodoComment;
143
144    #[test]
145    fn build_file_record_uses_layer0_defaults_and_git_signals() {
146        let analysis = StaticFileAnalysis {
147            path: "src/lib.rs".to_string(),
148            language: Language::Rust,
149            entry_points: vec!["run".to_string()],
150            exported_types: vec![],
151            imports: vec![ImportStatement::new("crate::utils", ImportKind::Normal, 1)],
152            todos: vec![TodoComment {
153                text: "TODO: tighten docs".to_string(),
154                line: 12,
155                kind: crate::store::record::TodoKind::Todo,
156            }],
157            unsafe_count: 1,
158            unwrap_count: 2,
159            panic_count: 0,
160            branch_count: 3,
161            module_doc: None,
162            content_hash: None,
163            line_count: 0,
164        };
165
166        let mut git = GitSignals::empty();
167        git.change_frequency.insert("src/lib.rs".to_string(), 9);
168        git.last_authors
169            .insert("src/lib.rs".to_string(), "ioni".to_string());
170        git.hotspot_files.push("src/lib.rs".to_string());
171
172        let file = WalkedFile {
173            abs_path: std::path::PathBuf::from("/repo/src/lib.rs"),
174            rel_path: "src/lib.rs".to_string(),
175            language: Language::Rust,
176            size_bytes: 400,
177            mtime_secs: 0,
178        };
179
180        let hotspots = git.hotspot_files.iter().cloned().collect::<HashSet<_>>();
181
182        let record = build_file_record(&file, &analysis, Some(&git), Some(&hotspots), 1234);
183
184        assert_eq!(record.path, "src/lib.rs");
185        assert!(record.purpose.is_empty());
186        assert_eq!(record.entry_points, vec!["run".to_string()]);
187        assert_eq!(record.imports, vec!["crate::utils".to_string()]);
188        assert_eq!(record.todos.len(), 1);
189        assert_eq!(record.unsafe_count, 1);
190        assert_eq!(record.unwrap_count, 2);
191        assert_eq!(record.change_frequency, 9);
192        assert_eq!(record.last_author.as_deref(), Some("ioni"));
193        assert!(record.is_hotspot);
194        assert_eq!(record.token_cost_estimate, 100);
195        assert_eq!(record.last_modified_session, 1234);
196    }
197
198    #[test]
199    fn module_doc_propagates_to_purpose() {
200        let analysis = StaticFileAnalysis {
201            path: "src/auth.rs".to_string(),
202            language: Language::Rust,
203            entry_points: vec![],
204            exported_types: vec![],
205            imports: vec![],
206            todos: vec![],
207            unsafe_count: 0,
208            unwrap_count: 0,
209            panic_count: 0,
210            branch_count: 0,
211            module_doc: Some("Handles JWT authentication.".to_string()),
212            content_hash: None,
213            line_count: 0,
214        };
215        let file = WalkedFile {
216            abs_path: std::path::PathBuf::from("/repo/src/auth.rs"),
217            rel_path: "src/auth.rs".to_string(),
218            language: Language::Rust,
219            size_bytes: 100,
220            mtime_secs: 0,
221        };
222        let record = build_file_record(&file, &analysis, None, None, 0);
223        assert_eq!(record.purpose, "Handles JWT authentication.");
224    }
225
226    #[test]
227    fn exported_types_are_folded_into_stored_api_surface() {
228        let analysis = StaticFileAnalysis {
229            path: "src/models.rs".to_string(),
230            language: Language::Rust,
231            entry_points: vec!["build".to_string()],
232            exported_types: vec!["Widget".to_string(), "Widget".to_string()],
233            imports: vec![],
234            todos: vec![],
235            unsafe_count: 0,
236            unwrap_count: 0,
237            panic_count: 0,
238            branch_count: 0,
239            module_doc: None,
240            content_hash: None,
241            line_count: 0,
242        };
243        let file = WalkedFile {
244            abs_path: std::path::PathBuf::from("/repo/src/models.rs"),
245            rel_path: "src/models.rs".to_string(),
246            language: Language::Rust,
247            size_bytes: 100,
248            mtime_secs: 0,
249        };
250
251        let record = build_file_record(&file, &analysis, None, None, 0);
252        assert_eq!(
253            record.entry_points,
254            vec!["build".to_string(), "Widget".to_string()]
255        );
256    }
257
258    #[test]
259    fn build_file_records_is_stable_for_missing_git_signals() {
260        let files = vec![WalkedFile {
261            abs_path: std::path::PathBuf::from("/repo/src/main.rs"),
262            rel_path: "src/main.rs".to_string(),
263            language: Language::Rust,
264            size_bytes: 8,
265            mtime_secs: 0,
266        }];
267        let analyses = vec![StaticFileAnalysis {
268            path: "src/main.rs".to_string(),
269            language: Language::Rust,
270            entry_points: vec![],
271            exported_types: vec![],
272            imports: vec![],
273            todos: vec![],
274            unsafe_count: 0,
275            unwrap_count: 0,
276            panic_count: 0,
277            branch_count: 0,
278            module_doc: None,
279            content_hash: None,
280            line_count: 0,
281        }];
282
283        let records = build_file_records(&files, &analyses, None, 55);
284
285        assert_eq!(records.len(), 1);
286        assert_eq!(records[0].path, "src/main.rs");
287        assert_eq!(records[0].change_frequency, 0);
288        assert!(records[0].last_author.is_none());
289        assert!(!records[0].is_hotspot);
290        assert_eq!(records[0].token_cost_estimate, 2);
291    }
292}