1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9 BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10 ProductionFunction,
11};
12
13use super::PythonExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
19static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
22static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
23
24const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
25static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
26
27const BARE_IMPORT_ATTRIBUTE_QUERY: &str = include_str!("../queries/bare_import_attribute.scm");
28static BARE_IMPORT_ATTRIBUTE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
29
30const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
31static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
32
33const ASSIGNMENT_MAPPING_QUERY: &str = include_str!("../queries/assignment_mapping.scm");
34static ASSIGNMENT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35
36fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
37 lock.get_or_init(|| {
38 Query::new(&tree_sitter_python::LANGUAGE.into(), source).expect("invalid query")
39 })
40}
41
42pub fn test_stem(path: &str) -> Option<&str> {
51 let file_name = Path::new(path).file_name()?.to_str()?;
52 let stem = file_name.strip_suffix(".py")?;
54 if let Some(rest) = stem.strip_prefix("test_") {
56 return Some(rest);
57 }
58 if let Some(rest) = stem.strip_suffix("_test") {
60 return Some(rest);
61 }
62 None
63}
64
65pub fn production_stem(path: &str) -> Option<&str> {
71 let file_name = Path::new(path).file_name()?.to_str()?;
72 let stem = file_name.strip_suffix(".py")?;
73 if stem == "__init__" {
75 return None;
76 }
77 if stem.starts_with("test_") || stem.ends_with("_test") {
79 return None;
80 }
81 let stem = stem.strip_prefix('_').unwrap_or(stem);
82 let stem = stem.strip_suffix("__").unwrap_or(stem);
83 Some(stem)
84}
85
86pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
88 let in_test_dir = file_path
93 .split('/')
94 .any(|seg| seg == "tests" || seg == "test");
95
96 if in_test_dir {
97 return true;
98 }
99
100 let stem_only = Path::new(file_path)
104 .file_stem()
105 .and_then(|f| f.to_str())
106 .unwrap_or("");
107
108 if stem_only == "__version__" {
110 return true;
111 }
112
113 {
115 let normalized = stem_only.trim_matches('_');
116 if normalized == "types" || normalized.ends_with("_types") {
117 return true;
118 }
119 }
120
121 if stem_only == "mock" || stem_only.starts_with("mock_") {
123 return true;
124 }
125
126 if is_known_production {
127 return false;
128 }
129
130 let file_name = Path::new(file_path)
131 .file_name()
132 .and_then(|f| f.to_str())
133 .unwrap_or("");
134
135 if matches!(
137 file_name,
138 "conftest.py" | "constants.py" | "setup.py" | "__init__.py"
139 ) {
140 return true;
141 }
142
143 let parent_is_pycache = Path::new(file_path)
145 .parent()
146 .and_then(|p| p.file_name())
147 .and_then(|f| f.to_str())
148 .map(|s| s == "__pycache__")
149 .unwrap_or(false);
150
151 if parent_is_pycache {
152 return true;
153 }
154
155 false
156}
157
158fn extract_bare_import_attributes(
167 source_bytes: &[u8],
168 tree: &tree_sitter::Tree,
169 module_name: &str,
170) -> Vec<String> {
171 let query = cached_query(
172 &BARE_IMPORT_ATTRIBUTE_QUERY_CACHE,
173 BARE_IMPORT_ATTRIBUTE_QUERY,
174 );
175 let module_name_idx = query.capture_index_for_name("module_name").unwrap();
176 let attribute_name_idx = query.capture_index_for_name("attribute_name").unwrap();
177
178 let mut cursor = QueryCursor::new();
179 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
180
181 let mut attrs: Vec<String> = Vec::new();
182 while let Some(m) = matches.next() {
183 let mut mod_text = "";
184 let mut attr_text = "";
185 for cap in m.captures {
186 if cap.index == module_name_idx {
187 mod_text = cap.node.utf8_text(source_bytes).unwrap_or("");
188 } else if cap.index == attribute_name_idx {
189 attr_text = cap.node.utf8_text(source_bytes).unwrap_or("");
190 }
191 }
192 if mod_text == module_name && !attr_text.is_empty() {
193 attrs.push(attr_text.to_string());
194 }
195 }
196 attrs.sort();
197 attrs.dedup();
198 attrs
199}
200
201impl ObserveExtractor for PythonExtractor {
206 fn extract_production_functions(
207 &self,
208 source: &str,
209 file_path: &str,
210 ) -> Vec<ProductionFunction> {
211 let mut parser = Self::parser();
212 let tree = match parser.parse(source, None) {
213 Some(t) => t,
214 None => return Vec::new(),
215 };
216 let source_bytes = source.as_bytes();
217 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
218
219 let name_idx = query.capture_index_for_name("name");
221 let class_name_idx = query.capture_index_for_name("class_name");
222 let method_name_idx = query.capture_index_for_name("method_name");
223 let decorated_name_idx = query.capture_index_for_name("decorated_name");
224 let decorated_class_name_idx = query.capture_index_for_name("decorated_class_name");
225 let decorated_method_name_idx = query.capture_index_for_name("decorated_method_name");
226
227 let fn_name_indices: [Option<u32>; 4] = [
229 name_idx,
230 method_name_idx,
231 decorated_name_idx,
232 decorated_method_name_idx,
233 ];
234 let class_name_indices: [Option<u32>; 2] = [class_name_idx, decorated_class_name_idx];
236
237 let mut cursor = QueryCursor::new();
238 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
239 let mut result = Vec::new();
240
241 while let Some(m) = matches.next() {
242 let mut fn_name: Option<String> = None;
244 let mut class_name: Option<String> = None;
245 let mut line: usize = 1;
246
247 for cap in m.captures {
248 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
249 let node_line = cap.node.start_position().row + 1;
250
251 if fn_name_indices.contains(&Some(cap.index)) {
252 fn_name = Some(text);
253 line = node_line;
254 } else if class_name_indices.contains(&Some(cap.index)) {
255 class_name = Some(text);
256 }
257 }
258
259 if let Some(name) = fn_name {
260 result.push(ProductionFunction {
261 name,
262 file: file_path.to_string(),
263 line,
264 class_name,
265 is_exported: true,
266 });
267 }
268 }
269
270 let mut seen = HashSet::new();
272 result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
273
274 result
275 }
276
277 fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
278 let mut parser = Self::parser();
279 let tree = match parser.parse(source, None) {
280 Some(t) => t,
281 None => return Vec::new(),
282 };
283 let source_bytes = source.as_bytes();
284 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
285
286 let module_name_idx = query.capture_index_for_name("module_name");
287 let symbol_name_idx = query.capture_index_for_name("symbol_name");
288
289 let mut cursor = QueryCursor::new();
290 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
291
292 let mut raw: Vec<(String, String, usize)> = Vec::new();
294
295 while let Some(m) = matches.next() {
296 let mut module_text: Option<String> = None;
297 let mut symbol_text: Option<String> = None;
298 let mut symbol_line: usize = 1;
299
300 for cap in m.captures {
301 if module_name_idx == Some(cap.index) {
302 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
303 } else if symbol_name_idx == Some(cap.index) {
304 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
305 symbol_line = cap.node.start_position().row + 1;
306 }
307 }
308
309 let (module_text, symbol_text) = match (module_text, symbol_text) {
310 (Some(m), Some(s)) => (m, s),
311 _ => continue,
312 };
313
314 let specifier_base = python_module_to_relative_specifier(&module_text);
319
320 if specifier_base.starts_with("./") || specifier_base.starts_with("../") {
322 let specifier = if specifier_base == "./"
325 && !module_text.contains('/')
326 && module_text.chars().all(|c| c == '.')
327 {
328 format!("./{symbol_text}")
329 } else {
330 specifier_base
331 };
332 raw.push((specifier, symbol_text, symbol_line));
333 }
334 }
335
336 let mut specifier_symbols: HashMap<String, Vec<(String, usize)>> = HashMap::new();
338 for (spec, sym, line) in &raw {
339 specifier_symbols
340 .entry(spec.clone())
341 .or_default()
342 .push((sym.clone(), *line));
343 }
344
345 let mut result = Vec::new();
347 for (specifier, sym_lines) in &specifier_symbols {
348 let all_symbols: Vec<String> = sym_lines.iter().map(|(s, _)| s.clone()).collect();
349 for (sym, line) in sym_lines {
350 result.push(ImportMapping {
351 symbol_name: sym.clone(),
352 module_specifier: specifier.clone(),
353 file: file_path.to_string(),
354 line: *line,
355 symbols: all_symbols.clone(),
356 });
357 }
358 }
359
360 result
361 }
362
363 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
364 let mut parser = Self::parser();
365 let tree = match parser.parse(source, None) {
366 Some(t) => t,
367 None => return Vec::new(),
368 };
369 let source_bytes = source.as_bytes();
370 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
371
372 let module_name_idx = query.capture_index_for_name("module_name");
373 let symbol_name_idx = query.capture_index_for_name("symbol_name");
374 let import_name_idx = query.capture_index_for_name("import_name");
375
376 let mut cursor = QueryCursor::new();
377 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
378
379 let mut specifier_symbols: HashMap<String, Vec<String>> = HashMap::new();
380
381 while let Some(m) = matches.next() {
382 let mut module_text: Option<String> = None;
383 let mut symbol_text: Option<String> = None;
384 let mut import_name_parts: Vec<String> = Vec::new();
385
386 for cap in m.captures {
387 if module_name_idx == Some(cap.index) {
388 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
389 } else if symbol_name_idx == Some(cap.index) {
390 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
391 } else if import_name_idx == Some(cap.index) {
392 let dotted_text = cap
395 .node
396 .parent()
397 .and_then(|p| p.utf8_text(source_bytes).ok())
398 .unwrap_or_else(|| cap.node.utf8_text(source_bytes).unwrap_or(""))
399 .to_string();
400 import_name_parts.push(dotted_text);
401 }
402 }
403
404 if !import_name_parts.is_empty() {
405 import_name_parts.dedup();
408 let specifier = python_module_to_absolute_specifier(&import_name_parts[0]);
409 if !specifier.starts_with("./")
410 && !specifier.starts_with("../")
411 && !specifier.is_empty()
412 {
413 let attrs =
414 extract_bare_import_attributes(source_bytes, &tree, &import_name_parts[0]);
415 specifier_symbols.entry(specifier).or_insert_with(|| attrs);
416 }
417 continue;
418 }
419
420 let (module_text, symbol_text) = match (module_text, symbol_text) {
421 (Some(m), Some(s)) => (m, s),
422 _ => continue,
423 };
424
425 let specifier = python_module_to_absolute_specifier(&module_text);
427
428 if specifier.starts_with("./") || specifier.starts_with("../") || specifier.is_empty() {
431 continue;
432 }
433
434 specifier_symbols
435 .entry(specifier)
436 .or_default()
437 .push(symbol_text);
438 }
439
440 specifier_symbols.into_iter().collect()
441 }
442
443 fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
444 let mut parser = Self::parser();
445 let tree = match parser.parse(source, None) {
446 Some(t) => t,
447 None => return Vec::new(),
448 };
449 let source_bytes = source.as_bytes();
450 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
451
452 let from_specifier_idx = query
453 .capture_index_for_name("from_specifier")
454 .expect("@from_specifier capture not found in re_export.scm");
455 let symbol_name_idx = query.capture_index_for_name("symbol_name");
456 let wildcard_idx = query.capture_index_for_name("wildcard");
457
458 let mut cursor = QueryCursor::new();
459 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
460
461 struct ReExportEntry {
463 symbols: Vec<String>,
464 wildcard: bool,
465 }
466 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
467
468 while let Some(m) = matches.next() {
469 let mut from_spec: Option<String> = None;
470 let mut sym: Option<String> = None;
471 let mut is_wildcard = false;
472
473 for cap in m.captures {
474 if cap.index == from_specifier_idx {
475 let raw = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
476 from_spec = Some(python_module_to_relative_specifier(&raw));
477 } else if wildcard_idx == Some(cap.index) {
478 is_wildcard = true;
479 } else if symbol_name_idx == Some(cap.index) {
480 sym = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
481 }
482 }
483
484 if let Some(spec) = from_spec {
485 if spec.starts_with("./") || spec.starts_with("../") {
487 let entry = grouped.entry(spec).or_insert(ReExportEntry {
488 symbols: Vec::new(),
489 wildcard: false,
490 });
491 if is_wildcard {
492 entry.wildcard = true;
493 }
494 if let Some(symbol) = sym {
495 if !entry.symbols.contains(&symbol) {
496 entry.symbols.push(symbol);
497 }
498 }
499 }
500 }
501 }
502
503 grouped
504 .into_iter()
505 .map(|(from_specifier, entry)| BarrelReExport {
506 symbols: entry.symbols,
507 from_specifier,
508 wildcard: entry.wildcard,
509 namespace_wildcard: false,
510 })
511 .collect()
512 }
513
514 fn source_extensions(&self) -> &[&str] {
515 &["py"]
516 }
517
518 fn index_file_names(&self) -> &[&str] {
519 &["__init__.py"]
520 }
521
522 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
523 production_stem(path)
524 }
525
526 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
527 test_stem(path)
528 }
529
530 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
531 is_non_sut_helper(file_path, is_known_production)
532 }
533
534 fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
535 let source = match std::fs::read_to_string(file_path) {
536 Ok(s) => s,
537 Err(_) => return true, };
539
540 let mut parser = Self::parser();
541 let tree = match parser.parse(&source, None) {
542 Some(t) => t,
543 None => return true,
544 };
545 let source_bytes = source.as_bytes();
546 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
547
548 let symbol_idx = query.capture_index_for_name("symbol");
549 let all_decl_idx = query.capture_index_for_name("all_decl");
550 let var_name_idx = query.capture_index_for_name("var_name");
551
552 let mut cursor = QueryCursor::new();
553 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
554
555 let mut all_symbols: Vec<String> = Vec::new();
556 let mut found_all = false;
557
558 while let Some(m) = matches.next() {
559 for cap in m.captures {
560 if var_name_idx == Some(cap.index) || all_decl_idx == Some(cap.index) {
562 found_all = true;
563 } else if symbol_idx == Some(cap.index) {
564 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
565 let stripped = raw.trim_matches(|c| c == '\'' || c == '"');
566 all_symbols.push(stripped.to_string());
567 }
568 }
569 }
570
571 if !found_all {
572 return true;
574 }
575
576 symbols.iter().any(|s| all_symbols.contains(s))
578 }
579}
580
581fn python_module_to_relative_specifier(module: &str) -> String {
597 let dot_count = module.chars().take_while(|&c| c == '.').count();
599 if dot_count == 0 {
600 return module.to_string();
602 }
603
604 let rest = &module[dot_count..];
605
606 if dot_count == 1 {
607 if rest.is_empty() {
608 "./".to_string()
611 } else {
612 format!("./{rest}")
613 }
614 } else {
615 let prefix = "../".repeat(dot_count - 1);
617 if rest.is_empty() {
618 prefix
620 } else {
621 format!("{prefix}{rest}")
622 }
623 }
624}
625
626fn python_module_to_absolute_specifier(module: &str) -> String {
630 if module.starts_with('.') {
631 return python_module_to_relative_specifier(module);
633 }
634 module.replace('.', "/")
635}
636
637pub fn extract_assertion_referenced_imports(source: &str) -> HashSet<String> {
658 let mut parser = PythonExtractor::parser();
659 let tree = match parser.parse(source, None) {
660 Some(t) => t,
661 None => return HashSet::new(),
662 };
663 let source_bytes = source.as_bytes();
664
665 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
667 let assertion_cap_idx = match assertion_query.capture_index_for_name("assertion") {
668 Some(idx) => idx,
669 None => return HashSet::new(),
670 };
671
672 let mut assertion_ranges: Vec<(usize, usize)> = Vec::new();
673 {
674 let mut cursor = QueryCursor::new();
675 let mut matches = cursor.matches(assertion_query, tree.root_node(), source_bytes);
676 while let Some(m) = matches.next() {
677 for cap in m.captures {
678 if cap.index == assertion_cap_idx {
679 let r = cap.node.byte_range();
680 assertion_ranges.push((r.start, r.end));
681 }
682 }
683 }
684 }
685
686 if assertion_ranges.is_empty() {
687 return HashSet::new();
688 }
689
690 let mut assertion_identifiers: HashSet<String> = HashSet::new();
692 {
693 let root = tree.root_node();
694 let mut stack = vec![root];
695 while let Some(node) = stack.pop() {
696 let nr = node.byte_range();
697 let overlaps = assertion_ranges
699 .iter()
700 .any(|&(s, e)| nr.start < e && nr.end > s);
701 if !overlaps {
702 continue;
703 }
704 if node.kind() == "identifier" {
705 if assertion_ranges
707 .iter()
708 .any(|&(s, e)| nr.start >= s && nr.end <= e)
709 {
710 if let Ok(text) = node.utf8_text(source_bytes) {
711 if !text.is_empty() {
712 assertion_identifiers.insert(text.to_string());
713 }
714 }
715 }
716 }
717 for i in 0..node.child_count() {
718 if let Some(child) = node.child(i) {
719 stack.push(child);
720 }
721 }
722 }
723 }
724
725 let assign_query = cached_query(&ASSIGNMENT_MAPPING_QUERY_CACHE, ASSIGNMENT_MAPPING_QUERY);
728 let var_idx = assign_query.capture_index_for_name("var");
729 let class_idx = assign_query.capture_index_for_name("class");
730 let source_idx = assign_query.capture_index_for_name("source");
731
732 let mut assignment_map: HashMap<String, Vec<String>> = HashMap::new();
734
735 if let (Some(var_cap), Some(class_cap), Some(source_cap)) = (var_idx, class_idx, source_idx) {
736 let mut cursor = QueryCursor::new();
737 let mut matches = cursor.matches(assign_query, tree.root_node(), source_bytes);
738 while let Some(m) = matches.next() {
739 let mut var_text = String::new();
740 let mut target_text = String::new();
741 for cap in m.captures {
742 if cap.index == var_cap {
743 var_text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
744 } else if cap.index == class_cap || cap.index == source_cap {
745 let t = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
746 if !t.is_empty() {
747 target_text = t;
748 }
749 }
750 }
751 if !var_text.is_empty() && !target_text.is_empty() && var_text != target_text {
752 assignment_map
753 .entry(var_text)
754 .or_default()
755 .push(target_text);
756 }
757 }
758 }
759
760 let mut resolved: HashSet<String> = assertion_identifiers.clone();
762 for _ in 0..2 {
763 let mut additions: HashSet<String> = HashSet::new();
764 for sym in &resolved {
765 if let Some(targets) = assignment_map.get(sym) {
766 for t in targets {
767 additions.insert(t.clone());
768 }
769 }
770 }
771 let before = resolved.len();
772 resolved.extend(additions);
773 if resolved.len() == before {
774 break;
775 }
776 }
777
778 resolved
779}
780
781fn track_new_matches(
785 all_matched: &HashSet<usize>,
786 before: &HashSet<usize>,
787 symbols: &[String],
788 idx_to_symbols: &mut HashMap<usize, HashSet<String>>,
789) {
790 for &new_idx in all_matched.difference(before) {
791 let entry = idx_to_symbols.entry(new_idx).or_default();
792 for s in symbols {
793 entry.insert(s.clone());
794 }
795 }
796}
797
798impl PythonExtractor {
799 pub fn map_test_files_with_imports(
801 &self,
802 production_files: &[String],
803 test_sources: &HashMap<String, String>,
804 scan_root: &Path,
805 ) -> Vec<FileMapping> {
806 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
807
808 let canonical_root_for_filter = scan_root.canonicalize().ok();
816 let filtered_production_files: Vec<String> = production_files
817 .iter()
818 .filter(|p| {
819 let check_path = if let Some(ref root) = canonical_root_for_filter {
820 if let Ok(canonical_p) = Path::new(p).canonicalize() {
821 if let Ok(rel) = canonical_p.strip_prefix(root) {
822 rel.to_string_lossy().into_owned()
823 } else {
824 p.to_string()
825 }
826 } else {
827 p.to_string()
828 }
829 } else {
830 p.to_string()
831 };
832 !is_non_sut_helper(&check_path, false)
833 })
834 .cloned()
835 .collect();
836
837 let mut mappings =
839 exspec_core::observe::map_test_files(self, &filtered_production_files, &test_file_list);
840
841 let canonical_root = match scan_root.canonicalize() {
843 Ok(r) => r,
844 Err(_) => return mappings,
845 };
846 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
847 for (idx, prod) in filtered_production_files.iter().enumerate() {
848 if let Ok(canonical) = Path::new(prod).canonicalize() {
849 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
850 }
851 }
852
853 let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
855 .iter()
856 .map(|m| m.test_files.iter().cloned().collect())
857 .collect();
858
859 {
863 let mut stem_to_prod_indices: HashMap<String, Vec<usize>> = HashMap::new();
865 for (idx, prod) in filtered_production_files.iter().enumerate() {
866 if let Some(pstem) = self.production_stem(prod) {
867 stem_to_prod_indices
868 .entry(pstem.to_owned())
869 .or_default()
870 .push(idx);
871 }
872 }
873
874 let l1_core_matched: HashSet<&str> = layer1_tests_per_prod
876 .iter()
877 .flat_map(|s| s.iter().map(|t| t.as_str()))
878 .collect();
879
880 for test_file in &test_file_list {
881 if l1_core_matched.contains(test_file.as_str()) {
883 continue;
884 }
885 if let Some(tstem) = self.test_stem(test_file) {
886 if let Some(prod_indices) = stem_to_prod_indices.get(tstem) {
887 for &idx in prod_indices {
888 if !mappings[idx].test_files.contains(test_file) {
889 mappings[idx].test_files.push(test_file.clone());
890 }
891 }
892 }
893 }
894 }
895 }
896
897 let layer1_extended_tests_per_prod: Vec<HashSet<String>> = mappings
899 .iter()
900 .map(|m| m.test_files.iter().cloned().collect())
901 .collect();
902
903 let l1_matched_tests: HashSet<String> = mappings
905 .iter()
906 .flat_map(|m| m.test_files.iter().cloned())
907 .collect();
908
909 for (test_file, source) in test_sources {
911 let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
912 let from_file = Path::new(test_file);
913 let mut all_matched = HashSet::<usize>::new();
915 let mut idx_to_symbols: HashMap<usize, HashSet<String>> = HashMap::new();
917
918 for import in &imports {
919 let is_bare_relative = (import.module_specifier == "./"
923 || import.module_specifier.ends_with('/'))
924 && import
925 .module_specifier
926 .trim_end_matches('/')
927 .chars()
928 .all(|c| c == '.');
929
930 let specifier = if is_bare_relative {
931 let prefix =
932 &import.module_specifier[..import.module_specifier.len().saturating_sub(1)];
933 for sym in &import.symbols {
934 let sym_specifier = format!("{prefix}/{sym}");
935 if let Some(resolved) = exspec_core::observe::resolve_import_path(
936 self,
937 &sym_specifier,
938 from_file,
939 &canonical_root,
940 ) {
941 if self.is_barrel_file(&resolved)
943 && l1_matched_tests.contains(test_file.as_str())
944 {
945 continue;
946 }
947 let sym_slice = &[sym.clone()];
948 let before = all_matched.clone();
949 exspec_core::observe::collect_import_matches(
950 self,
951 &resolved,
952 sym_slice,
953 &canonical_to_idx,
954 &mut all_matched,
955 &canonical_root,
956 );
957 track_new_matches(
958 &all_matched,
959 &before,
960 sym_slice,
961 &mut idx_to_symbols,
962 );
963 }
964 }
965 continue;
966 } else {
967 import.module_specifier.clone()
968 };
969
970 if let Some(resolved) = exspec_core::observe::resolve_import_path(
971 self,
972 &specifier,
973 from_file,
974 &canonical_root,
975 ) {
976 if self.is_barrel_file(&resolved)
978 && l1_matched_tests.contains(test_file.as_str())
979 {
980 continue;
981 }
982 let before = all_matched.clone();
983 exspec_core::observe::collect_import_matches(
984 self,
985 &resolved,
986 &import.symbols,
987 &canonical_to_idx,
988 &mut all_matched,
989 &canonical_root,
990 );
991 track_new_matches(&all_matched, &before, &import.symbols, &mut idx_to_symbols);
992 }
993 }
994
995 let abs_specifiers = self.extract_all_import_specifiers(source);
997 for (specifier, symbols) in &abs_specifiers {
998 let base = canonical_root.join(specifier);
999 let resolved = exspec_core::observe::resolve_absolute_base_to_file(
1000 self,
1001 &base,
1002 &canonical_root,
1003 )
1004 .or_else(|| {
1005 let src_base = canonical_root.join("src").join(specifier);
1006 exspec_core::observe::resolve_absolute_base_to_file(
1007 self,
1008 &src_base,
1009 &canonical_root,
1010 )
1011 });
1012 if let Some(resolved) = resolved {
1013 if self.is_barrel_file(&resolved)
1015 && l1_matched_tests.contains(test_file.as_str())
1016 {
1017 continue;
1018 }
1019 let before = all_matched.clone();
1020 exspec_core::observe::collect_import_matches(
1021 self,
1022 &resolved,
1023 symbols,
1024 &canonical_to_idx,
1025 &mut all_matched,
1026 &canonical_root,
1027 );
1028 track_new_matches(&all_matched, &before, symbols, &mut idx_to_symbols);
1029 }
1030 }
1031
1032 let asserted_imports = extract_assertion_referenced_imports(source);
1034 let final_indices: HashSet<usize> = if asserted_imports.is_empty() {
1035 all_matched.clone()
1037 } else {
1038 let asserted_matched: HashSet<usize> = all_matched
1040 .iter()
1041 .copied()
1042 .filter(|idx| {
1043 idx_to_symbols
1044 .get(idx)
1045 .map(|syms| syms.iter().any(|s| asserted_imports.contains(s)))
1046 .unwrap_or(false)
1047 })
1048 .collect();
1049 if asserted_matched.is_empty() {
1050 all_matched.clone()
1052 } else {
1053 asserted_matched
1054 }
1055 };
1056
1057 for idx in final_indices {
1058 if !mappings[idx].test_files.contains(test_file) {
1059 mappings[idx].test_files.push(test_file.clone());
1060 }
1061 }
1062 }
1063
1064 for (i, mapping) in mappings.iter_mut().enumerate() {
1067 let has_layer1 = !layer1_extended_tests_per_prod[i].is_empty();
1068 if !has_layer1 && !mapping.test_files.is_empty() {
1069 mapping.strategy = MappingStrategy::ImportTracing;
1070 }
1071 }
1072
1073 mappings
1074 }
1075}
1076
1077#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use std::path::PathBuf;
1085
1086 #[test]
1090 fn py_stem_01_test_prefix() {
1091 let extractor = PythonExtractor::new();
1095 let result = extractor.test_stem("tests/test_user.py");
1096 assert_eq!(result, Some("user"));
1097 }
1098
1099 #[test]
1103 fn py_stem_02_test_suffix() {
1104 let extractor = PythonExtractor::new();
1108 let result = extractor.test_stem("tests/user_test.py");
1109 assert_eq!(result, Some("user"));
1110 }
1111
1112 #[test]
1116 fn py_stem_03_test_prefix_multi_segment() {
1117 let extractor = PythonExtractor::new();
1121 let result = extractor.test_stem("tests/test_user_service.py");
1122 assert_eq!(result, Some("user_service"));
1123 }
1124
1125 #[test]
1129 fn py_stem_04_production_stem_regular() {
1130 let extractor = PythonExtractor::new();
1134 let result = extractor.production_stem("src/user.py");
1135 assert_eq!(result, Some("user"));
1136 }
1137
1138 #[test]
1142 fn py_stem_05_production_stem_init() {
1143 let extractor = PythonExtractor::new();
1147 let result = extractor.production_stem("src/__init__.py");
1148 assert_eq!(result, None);
1149 }
1150
1151 #[test]
1155 fn py_stem_06_production_stem_test_file() {
1156 let extractor = PythonExtractor::new();
1160 let result = extractor.production_stem("tests/test_user.py");
1161 assert_eq!(result, None);
1162 }
1163
1164 #[test]
1168 fn py_helper_01_conftest() {
1169 let extractor = PythonExtractor::new();
1173 assert!(extractor.is_non_sut_helper("tests/conftest.py", false));
1174 }
1175
1176 #[test]
1180 fn py_helper_02_constants() {
1181 let extractor = PythonExtractor::new();
1185 assert!(extractor.is_non_sut_helper("src/constants.py", false));
1186 }
1187
1188 #[test]
1192 fn py_helper_03_init() {
1193 let extractor = PythonExtractor::new();
1197 assert!(extractor.is_non_sut_helper("src/__init__.py", false));
1198 }
1199
1200 #[test]
1204 fn py_helper_04_utils_under_tests_dir() {
1205 let extractor = PythonExtractor::new();
1209 assert!(extractor.is_non_sut_helper("tests/utils.py", false));
1210 }
1211
1212 #[test]
1216 fn py_helper_05_models_is_not_helper() {
1217 let extractor = PythonExtractor::new();
1221 assert!(!extractor.is_non_sut_helper("src/models.py", false));
1222 }
1223
1224 #[test]
1228 fn py_helper_06_tests_common_helper_despite_known_production() {
1229 let extractor = PythonExtractor::new();
1233 assert!(extractor.is_non_sut_helper("tests/common.py", true));
1234 }
1235
1236 #[test]
1240 fn py_helper_07_tests_subdirectory_helper() {
1241 let extractor = PythonExtractor::new();
1245 assert!(extractor.is_non_sut_helper("tests/testserver/server.py", true));
1246 }
1247
1248 #[test]
1252 fn py_helper_08_tests_compat_helper() {
1253 let extractor = PythonExtractor::new();
1257 assert!(extractor.is_non_sut_helper("tests/compat.py", false));
1258 }
1259
1260 #[test]
1264 fn py_helper_09_deep_nested_test_dir_helper() {
1265 let extractor = PythonExtractor::new();
1269 assert!(extractor.is_non_sut_helper("tests/fixtures/data.py", false));
1270 }
1271
1272 #[test]
1276 fn py_helper_10_tests_in_filename_not_helper() {
1277 let extractor = PythonExtractor::new();
1281 assert!(!extractor.is_non_sut_helper("src/tests.py", false));
1282 }
1283
1284 #[test]
1288 fn py_helper_11_test_singular_dir_helper() {
1289 let extractor = PythonExtractor::new();
1293 assert!(extractor.is_non_sut_helper("test/helpers.py", true));
1294 }
1295
1296 #[test]
1300 fn py_barrel_01_init_is_barrel() {
1301 let extractor = PythonExtractor::new();
1305 assert!(extractor.is_barrel_file("src/mypackage/__init__.py"));
1306 }
1307
1308 #[test]
1312 fn py_func_01_top_level_function() {
1313 let source = r#"
1315def create_user():
1316 pass
1317"#;
1318 let extractor = PythonExtractor::new();
1320 let result = extractor.extract_production_functions(source, "src/users.py");
1321
1322 let func = result.iter().find(|f| f.name == "create_user");
1324 assert!(func.is_some(), "create_user not found in {:?}", result);
1325 let func = func.unwrap();
1326 assert_eq!(func.class_name, None);
1327 }
1328
1329 #[test]
1333 fn py_func_02_class_method() {
1334 let source = r#"
1336class User:
1337 def save(self):
1338 pass
1339"#;
1340 let extractor = PythonExtractor::new();
1342 let result = extractor.extract_production_functions(source, "src/models.py");
1343
1344 let method = result.iter().find(|f| f.name == "save");
1346 assert!(method.is_some(), "save not found in {:?}", result);
1347 let method = method.unwrap();
1348 assert_eq!(method.class_name, Some("User".to_string()));
1349 }
1350
1351 #[test]
1355 fn py_func_03_decorated_function() {
1356 let source = r#"
1358import functools
1359
1360def my_decorator(func):
1361 @functools.wraps(func)
1362 def wrapper(*args, **kwargs):
1363 return func(*args, **kwargs)
1364 return wrapper
1365
1366@my_decorator
1367def endpoint():
1368 pass
1369"#;
1370 let extractor = PythonExtractor::new();
1372 let result = extractor.extract_production_functions(source, "src/views.py");
1373
1374 let func = result.iter().find(|f| f.name == "endpoint");
1376 assert!(func.is_some(), "endpoint not found in {:?}", result);
1377 }
1378
1379 #[test]
1383 fn py_imp_01_relative_import_from_dot() {
1384 let source = "from .models import User\n";
1386
1387 let extractor = PythonExtractor::new();
1389 let result = extractor.extract_imports(source, "tests/test_user.py");
1390
1391 let imp = result.iter().find(|i| i.module_specifier == "./models");
1393 assert!(
1394 imp.is_some(),
1395 "import from ./models not found in {:?}",
1396 result
1397 );
1398 let imp = imp.unwrap();
1399 assert!(
1400 imp.symbols.contains(&"User".to_string()),
1401 "User not in symbols: {:?}",
1402 imp.symbols
1403 );
1404 }
1405
1406 #[test]
1410 fn py_imp_02_relative_import_two_dots() {
1411 let source = "from ..utils import helper\n";
1413
1414 let extractor = PythonExtractor::new();
1416 let result = extractor.extract_imports(source, "tests/unit/test_something.py");
1417
1418 let imp = result.iter().find(|i| i.module_specifier == "../utils");
1420 assert!(
1421 imp.is_some(),
1422 "import from ../utils not found in {:?}",
1423 result
1424 );
1425 let imp = imp.unwrap();
1426 assert!(
1427 imp.symbols.contains(&"helper".to_string()),
1428 "helper not in symbols: {:?}",
1429 imp.symbols
1430 );
1431 }
1432
1433 #[test]
1437 fn py_imp_03_absolute_import_dotted() {
1438 let source = "from myapp.models import User\n";
1440
1441 let extractor = PythonExtractor::new();
1443 let result = extractor.extract_all_import_specifiers(source);
1444
1445 let entry = result.iter().find(|(spec, _)| spec == "myapp/models");
1447 assert!(entry.is_some(), "myapp/models not found in {:?}", result);
1448 let (_, symbols) = entry.unwrap();
1449 assert!(
1450 symbols.contains(&"User".to_string()),
1451 "User not in symbols: {:?}",
1452 symbols
1453 );
1454 }
1455
1456 #[test]
1460 fn py_imp_04_plain_import_skipped() {
1461 let source = "import os\n";
1463
1464 let extractor = PythonExtractor::new();
1466 let result = extractor.extract_all_import_specifiers(source);
1467
1468 let os_entry = result.iter().find(|(spec, _)| spec == "os");
1470 assert!(
1471 os_entry.is_some(),
1472 "plain 'import os' should be included as bare import, got {:?}",
1473 result
1474 );
1475 let (_, symbols) = os_entry.unwrap();
1476 assert!(
1477 symbols.is_empty(),
1478 "expected empty symbols for bare import, got {:?}",
1479 symbols
1480 );
1481 }
1482
1483 #[test]
1487 fn py_imp_05_from_dot_import_name() {
1488 let source = "from . import views\n";
1490
1491 let extractor = PythonExtractor::new();
1493 let result = extractor.extract_imports(source, "tests/test_app.py");
1494
1495 let imp = result.iter().find(|i| i.module_specifier == "./views");
1497 assert!(imp.is_some(), "./views not found in {:?}", result);
1498 let imp = imp.unwrap();
1499 assert!(
1500 imp.symbols.contains(&"views".to_string()),
1501 "views not in symbols: {:?}",
1502 imp.symbols
1503 );
1504 }
1505
1506 #[test]
1510 fn py_import_01_bare_import_simple() {
1511 let source = "import httpx\n";
1513
1514 let extractor = PythonExtractor::new();
1516 let result = extractor.extract_all_import_specifiers(source);
1517
1518 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1520 assert!(
1521 entry.is_some(),
1522 "httpx not found in {:?}; bare import should be included",
1523 result
1524 );
1525 let (_, symbols) = entry.unwrap();
1526 assert!(
1527 symbols.is_empty(),
1528 "expected empty symbols for bare import, got {:?}",
1529 symbols
1530 );
1531 }
1532
1533 #[test]
1537 fn py_import_02_bare_import_dotted() {
1538 let source = "import os.path\n";
1540
1541 let extractor = PythonExtractor::new();
1543 let result = extractor.extract_all_import_specifiers(source);
1544
1545 let entry = result.iter().find(|(spec, _)| spec == "os/path");
1547 assert!(
1548 entry.is_some(),
1549 "os/path not found in {:?}; dotted bare import should be converted",
1550 result
1551 );
1552 let (_, symbols) = entry.unwrap();
1553 assert!(
1554 symbols.is_empty(),
1555 "expected empty symbols for dotted bare import, got {:?}",
1556 symbols
1557 );
1558 }
1559
1560 #[test]
1565 fn py_import_03_from_import_regression() {
1566 let source = "from httpx import Client\n";
1568
1569 let extractor = PythonExtractor::new();
1571 let result = extractor.extract_all_import_specifiers(source);
1572
1573 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1575 assert!(entry.is_some(), "httpx not found in {:?}", result);
1576 let (_, symbols) = entry.unwrap();
1577 assert!(
1578 symbols.contains(&"Client".to_string()),
1579 "Client not in symbols: {:?}",
1580 symbols
1581 );
1582 }
1583
1584 #[test]
1589 fn py_barrel_02_re_export_named() {
1590 let source = "from .module import Foo\n";
1592
1593 let extractor = PythonExtractor::new();
1595 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1596
1597 let entry = result.iter().find(|e| e.from_specifier == "./module");
1599 assert!(entry.is_some(), "./module not found in {:?}", result);
1600 let entry = entry.unwrap();
1601 assert!(
1602 entry.symbols.contains(&"Foo".to_string()),
1603 "Foo not in symbols: {:?}",
1604 entry.symbols
1605 );
1606 }
1607
1608 #[test]
1612 fn py_barrel_03_all_exports_symbol_present() {
1613 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1616 .parent()
1617 .unwrap()
1618 .parent()
1619 .unwrap()
1620 .join("tests/fixtures/python/observe/barrel/__init__.py");
1621
1622 let extractor = PythonExtractor::new();
1624 let symbols = vec!["Foo".to_string()];
1625 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1626
1627 assert!(
1629 result,
1630 "expected file_exports_any_symbol to return true for Foo"
1631 );
1632 }
1633
1634 #[test]
1638 fn py_barrel_04_all_exports_symbol_absent() {
1639 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1641 .parent()
1642 .unwrap()
1643 .parent()
1644 .unwrap()
1645 .join("tests/fixtures/python/observe/barrel/__init__.py");
1646
1647 let extractor = PythonExtractor::new();
1649 let symbols = vec!["Bar".to_string()];
1650 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1651
1652 assert!(
1654 !result,
1655 "expected file_exports_any_symbol to return false for Bar"
1656 );
1657 }
1658
1659 #[test]
1663 fn py_barrel_05_re_export_wildcard() {
1664 let source = "from .module import *\n";
1666
1667 let extractor = PythonExtractor::new();
1669 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1670
1671 let entry = result.iter().find(|e| e.from_specifier == "./module");
1673 assert!(entry.is_some(), "./module not found in {:?}", result);
1674 let entry = entry.unwrap();
1675 assert!(entry.wildcard, "expected wildcard=true, got {:?}", entry);
1676 assert!(
1677 entry.symbols.is_empty(),
1678 "expected empty symbols for wildcard, got {:?}",
1679 entry.symbols
1680 );
1681 }
1682
1683 #[test]
1687 fn py_barrel_06_re_export_named_multi_symbol() {
1688 let source = "from .module import Foo, Bar\n";
1690
1691 let extractor = PythonExtractor::new();
1693 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1694
1695 let entry = result.iter().find(|e| e.from_specifier == "./module");
1697 assert!(entry.is_some(), "./module not found in {:?}", result);
1698 let entry = entry.unwrap();
1699 assert!(
1700 !entry.wildcard,
1701 "expected wildcard=false for named re-export, got {:?}",
1702 entry
1703 );
1704 assert!(
1705 entry.symbols.contains(&"Foo".to_string()),
1706 "Foo not in symbols: {:?}",
1707 entry.symbols
1708 );
1709 assert!(
1710 entry.symbols.contains(&"Bar".to_string()),
1711 "Bar not in symbols: {:?}",
1712 entry.symbols
1713 );
1714 }
1715
1716 #[test]
1722 fn py_barrel_07_e2e_wildcard_barrel_mapped() {
1723 use tempfile::TempDir;
1724
1725 let dir = TempDir::new().unwrap();
1726 let pkg = dir.path().join("pkg");
1727 std::fs::create_dir_all(&pkg).unwrap();
1728
1729 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1731 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1733 let tests_dir = dir.path().join("tests");
1735 std::fs::create_dir_all(&tests_dir).unwrap();
1736 std::fs::write(
1737 tests_dir.join("test_foo.py"),
1738 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1739 )
1740 .unwrap();
1741
1742 let extractor = PythonExtractor::new();
1743 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1744 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1745 let test_source = std::fs::read_to_string(&test_path).unwrap();
1746
1747 let production_files = vec![module_path.clone()];
1748 let test_sources: HashMap<String, String> =
1749 [(test_path.clone(), test_source)].into_iter().collect();
1750
1751 let result =
1753 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1754
1755 let mapping = result.iter().find(|m| m.production_file == module_path);
1757 assert!(
1758 mapping.is_some(),
1759 "module.py not found in mappings: {:?}",
1760 result
1761 );
1762 let mapping = mapping.unwrap();
1763 assert!(
1764 mapping.test_files.contains(&test_path),
1765 "test_foo.py not matched to module.py: {:?}",
1766 mapping.test_files
1767 );
1768 }
1769
1770 #[test]
1776 fn py_barrel_08_e2e_named_barrel_mapped() {
1777 use tempfile::TempDir;
1778
1779 let dir = TempDir::new().unwrap();
1780 let pkg = dir.path().join("pkg");
1781 std::fs::create_dir_all(&pkg).unwrap();
1782
1783 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
1785 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1787 let tests_dir = dir.path().join("tests");
1789 std::fs::create_dir_all(&tests_dir).unwrap();
1790 std::fs::write(
1791 tests_dir.join("test_foo.py"),
1792 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1793 )
1794 .unwrap();
1795
1796 let extractor = PythonExtractor::new();
1797 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1798 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1799 let test_source = std::fs::read_to_string(&test_path).unwrap();
1800
1801 let production_files = vec![module_path.clone()];
1802 let test_sources: HashMap<String, String> =
1803 [(test_path.clone(), test_source)].into_iter().collect();
1804
1805 let result =
1807 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1808
1809 let mapping = result.iter().find(|m| m.production_file == module_path);
1811 assert!(
1812 mapping.is_some(),
1813 "module.py not found in mappings: {:?}",
1814 result
1815 );
1816 let mapping = mapping.unwrap();
1817 assert!(
1818 mapping.test_files.contains(&test_path),
1819 "test_foo.py not matched to module.py: {:?}",
1820 mapping.test_files
1821 );
1822 }
1823
1824 #[test]
1830 fn py_barrel_09_e2e_wildcard_barrel_non_exported_not_mapped() {
1831 use tempfile::TempDir;
1832
1833 let dir = TempDir::new().unwrap();
1834 let pkg = dir.path().join("pkg");
1835 std::fs::create_dir_all(&pkg).unwrap();
1836
1837 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1839 std::fs::write(
1841 pkg.join("module.py"),
1842 "__all__ = [\"Foo\"]\n\nclass Foo:\n pass\n\nclass NonExistent:\n pass\n",
1843 )
1844 .unwrap();
1845 let tests_dir = dir.path().join("tests");
1847 std::fs::create_dir_all(&tests_dir).unwrap();
1848 std::fs::write(
1849 tests_dir.join("test_nonexistent.py"),
1850 "from pkg import NonExistent\n\ndef test_ne():\n assert NonExistent()\n",
1851 )
1852 .unwrap();
1853
1854 let extractor = PythonExtractor::new();
1855 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1856 let test_path = tests_dir
1857 .join("test_nonexistent.py")
1858 .to_string_lossy()
1859 .into_owned();
1860 let test_source = std::fs::read_to_string(&test_path).unwrap();
1861
1862 let production_files = vec![module_path.clone()];
1863 let test_sources: HashMap<String, String> =
1864 [(test_path.clone(), test_source)].into_iter().collect();
1865
1866 let result =
1868 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1869
1870 let mapping = result.iter().find(|m| m.production_file == module_path);
1873 if let Some(mapping) = mapping {
1874 assert!(
1875 !mapping.test_files.contains(&test_path),
1876 "test_nonexistent.py should NOT be matched to module.py: {:?}",
1877 mapping.test_files
1878 );
1879 }
1880 }
1882
1883 #[test]
1887 fn py_e2e_01_layer1_stem_match() {
1888 let extractor = PythonExtractor::new();
1890 let production_files = vec!["e2e_pkg/models.py".to_string()];
1891 let test_sources: HashMap<String, String> =
1892 [("e2e_pkg/test_models.py".to_string(), "".to_string())]
1893 .into_iter()
1894 .collect();
1895
1896 let scan_root = PathBuf::from(".");
1898 let result =
1899 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1900
1901 let mapping = result
1903 .iter()
1904 .find(|m| m.production_file == "e2e_pkg/models.py");
1905 assert!(
1906 mapping.is_some(),
1907 "models.py not found in mappings: {:?}",
1908 result
1909 );
1910 let mapping = mapping.unwrap();
1911 assert!(
1912 mapping
1913 .test_files
1914 .contains(&"e2e_pkg/test_models.py".to_string()),
1915 "test_models.py not in test_files: {:?}",
1916 mapping.test_files
1917 );
1918 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
1919 }
1920
1921 #[test]
1925 fn py_e2e_02_layer2_import_tracing() {
1926 let extractor = PythonExtractor::new();
1928
1929 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1930 .parent()
1931 .unwrap()
1932 .parent()
1933 .unwrap()
1934 .join("tests/fixtures/python/observe/e2e_pkg");
1935
1936 let views_path = fixture_root.join("views.py").to_string_lossy().into_owned();
1937 let test_views_path = fixture_root
1938 .join("tests/test_views.py")
1939 .to_string_lossy()
1940 .into_owned();
1941
1942 let test_source =
1943 std::fs::read_to_string(fixture_root.join("tests/test_views.py")).unwrap_or_default();
1944
1945 let production_files = vec![views_path.clone()];
1946 let test_sources: HashMap<String, String> = [(test_views_path.clone(), test_source)]
1947 .into_iter()
1948 .collect();
1949
1950 let result =
1952 extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
1953
1954 let mapping = result.iter().find(|m| m.production_file == views_path);
1956 assert!(
1957 mapping.is_some(),
1958 "views.py not found in mappings: {:?}",
1959 result
1960 );
1961 let mapping = mapping.unwrap();
1962 assert!(
1963 mapping.test_files.contains(&test_views_path),
1964 "test_views.py not matched to views.py: {:?}",
1965 mapping.test_files
1966 );
1967 }
1968
1969 #[test]
1973 fn py_e2e_03_conftest_excluded_as_helper() {
1974 let extractor = PythonExtractor::new();
1976 let production_files = vec!["e2e_pkg/models.py".to_string()];
1977 let test_sources: HashMap<String, String> = [
1978 ("e2e_pkg/tests/test_models.py".to_string(), "".to_string()),
1979 (
1980 "e2e_pkg/tests/conftest.py".to_string(),
1981 "import pytest\n".to_string(),
1982 ),
1983 ]
1984 .into_iter()
1985 .collect();
1986
1987 let scan_root = PathBuf::from(".");
1989 let result =
1990 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1991
1992 for mapping in &result {
1994 assert!(
1995 !mapping.test_files.iter().any(|f| f.contains("conftest.py")),
1996 "conftest.py should not appear in mappings: {:?}",
1997 mapping
1998 );
1999 }
2000 }
2001
2002 struct ImportTestResult {
2007 mappings: Vec<FileMapping>,
2008 prod_path: String,
2009 test_path: String,
2010 _tmp: tempfile::TempDir,
2011 }
2012
2013 fn run_import_test(
2017 prod_rel: &str,
2018 prod_content: &str,
2019 test_rel: &str,
2020 test_content: &str,
2021 extra_files: &[(&str, &str)],
2022 ) -> ImportTestResult {
2023 let tmp = tempfile::tempdir().unwrap();
2024
2025 for (rel, content) in extra_files {
2027 let path = tmp.path().join(rel);
2028 if let Some(parent) = path.parent() {
2029 std::fs::create_dir_all(parent).unwrap();
2030 }
2031 std::fs::write(&path, content).unwrap();
2032 }
2033
2034 let prod_abs = tmp.path().join(prod_rel);
2036 if let Some(parent) = prod_abs.parent() {
2037 std::fs::create_dir_all(parent).unwrap();
2038 }
2039 std::fs::write(&prod_abs, prod_content).unwrap();
2040
2041 let test_abs = tmp.path().join(test_rel);
2043 if let Some(parent) = test_abs.parent() {
2044 std::fs::create_dir_all(parent).unwrap();
2045 }
2046 std::fs::write(&test_abs, test_content).unwrap();
2047
2048 let extractor = PythonExtractor::new();
2049 let prod_path = prod_abs.to_string_lossy().into_owned();
2050 let test_path = test_abs.to_string_lossy().into_owned();
2051 let production_files = vec![prod_path.clone()];
2052 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2053 .into_iter()
2054 .collect();
2055
2056 let mappings =
2057 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2058
2059 ImportTestResult {
2060 mappings,
2061 prod_path,
2062 test_path,
2063 _tmp: tmp,
2064 }
2065 }
2066
2067 #[test]
2071 fn py_abs_01_absolute_import_nested_module() {
2072 let r = run_import_test(
2075 "models/cars.py",
2076 "class Car:\n pass\n",
2077 "tests/unit/test_car.py",
2078 "from models.cars import Car\n\ndef test_car():\n pass\n",
2079 &[],
2080 );
2081
2082 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2084 assert!(
2085 mapping.is_some(),
2086 "models/cars.py not found in mappings: {:?}",
2087 r.mappings
2088 );
2089 let mapping = mapping.unwrap();
2090 assert!(
2091 mapping.test_files.contains(&r.test_path),
2092 "test_car.py not in test_files for models/cars.py: {:?}",
2093 mapping.test_files
2094 );
2095 assert_eq!(
2096 mapping.strategy,
2097 MappingStrategy::ImportTracing,
2098 "expected ImportTracing strategy, got {:?}",
2099 mapping.strategy
2100 );
2101 }
2102
2103 #[test]
2107 fn py_abs_02_absolute_import_utils_module() {
2108 let r = run_import_test(
2111 "utils/publish_state.py",
2112 "class PublishState:\n pass\n",
2113 "tests/test_pub.py",
2114 "from utils.publish_state import PublishState\n\ndef test_pub():\n pass\n",
2115 &[],
2116 );
2117
2118 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2120 assert!(
2121 mapping.is_some(),
2122 "utils/publish_state.py not found in mappings: {:?}",
2123 r.mappings
2124 );
2125 let mapping = mapping.unwrap();
2126 assert!(
2127 mapping.test_files.contains(&r.test_path),
2128 "test_pub.py not in test_files for utils/publish_state.py: {:?}",
2129 mapping.test_files
2130 );
2131 assert_eq!(
2132 mapping.strategy,
2133 MappingStrategy::ImportTracing,
2134 "expected ImportTracing strategy, got {:?}",
2135 mapping.strategy
2136 );
2137 }
2138
2139 #[test]
2143 fn py_abs_03_relative_import_still_resolves() {
2144 let r = run_import_test(
2148 "pkg/models.py",
2149 "class X:\n pass\n",
2150 "pkg/test_something.py",
2151 "from .models import X\n\ndef test_x():\n pass\n",
2152 &[],
2153 );
2154
2155 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2157 assert!(
2158 mapping.is_some(),
2159 "pkg/models.py not found in mappings: {:?}",
2160 r.mappings
2161 );
2162 let mapping = mapping.unwrap();
2163 assert!(
2164 mapping.test_files.contains(&r.test_path),
2165 "test_something.py not in test_files for pkg/models.py: {:?}",
2166 mapping.test_files
2167 );
2168 }
2169
2170 #[test]
2174 fn py_stem_07_production_stem_single_underscore_prefix() {
2175 let extractor = PythonExtractor::new();
2179 let result = extractor.production_stem("httpx/_decoders.py");
2180 assert_eq!(result, Some("decoders"));
2181 }
2182
2183 #[test]
2187 fn py_stem_08_production_stem_double_underscore_strips_one() {
2188 let extractor = PythonExtractor::new();
2192 let result = extractor.production_stem("httpx/__version__.py");
2193 assert_eq!(result, Some("_version"));
2194 }
2195
2196 #[test]
2200 fn py_stem_09_production_stem_no_prefix_regression() {
2201 let extractor = PythonExtractor::new();
2205 let result = extractor.production_stem("httpx/decoders.py");
2206 assert_eq!(result, Some("decoders"));
2207 }
2208
2209 #[test]
2213 fn py_stem_10_production_stem_triple_underscore() {
2214 let extractor = PythonExtractor::new();
2218 let result = extractor.production_stem("pkg/___triple.py");
2219 assert_eq!(result, Some("__triple"));
2220 }
2221
2222 #[test]
2226 fn py_stem_11_production_stem_prefix_and_suffix_chained() {
2227 let extractor = PythonExtractor::new();
2231 let result = extractor.production_stem("pkg/___foo__.py");
2232 assert_eq!(result, Some("__foo"));
2233 }
2234
2235 #[test]
2239 fn py_stem_12_production_stem_dunder_prefix_and_suffix() {
2240 let extractor = PythonExtractor::new();
2244 let result = extractor.production_stem("pkg/__foo__.py");
2245 assert_eq!(result, Some("_foo"));
2246 }
2247
2248 #[test]
2252 fn py_srclayout_01_src_layout_absolute_import_resolved() {
2253 let r = run_import_test(
2256 "src/mypackage/sessions.py",
2257 "class Session:\n pass\n",
2258 "tests/test_sessions.py",
2259 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2260 &[("src/mypackage/__init__.py", "")],
2261 );
2262
2263 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2268 assert!(
2269 mapping.is_some(),
2270 "src/mypackage/sessions.py not found in mappings: {:?}",
2271 r.mappings
2272 );
2273 let mapping = mapping.unwrap();
2274 assert!(
2275 mapping.test_files.contains(&r.test_path),
2276 "test_sessions.py not in test_files for sessions.py (src/ layout): {:?}",
2277 mapping.test_files
2278 );
2279 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2280 }
2281
2282 #[test]
2286 fn py_srclayout_02_non_src_layout_regression() {
2287 let r = run_import_test(
2290 "mypackage/sessions.py",
2291 "class Session:\n pass\n",
2292 "tests/test_sessions.py",
2293 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2294 &[],
2295 );
2296
2297 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2302 assert!(
2303 mapping.is_some(),
2304 "mypackage/sessions.py not found in mappings: {:?}",
2305 r.mappings
2306 );
2307 let mapping = mapping.unwrap();
2308 assert!(
2309 mapping.test_files.contains(&r.test_path),
2310 "test_sessions.py not in test_files for sessions.py (non-src layout): {:?}",
2311 mapping.test_files
2312 );
2313 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2314 }
2315
2316 #[test]
2320 fn py_abs_04_nonexistent_absolute_import_skipped() {
2321 let r = run_import_test(
2325 "models/real.py",
2326 "class Real:\n pass\n",
2327 "tests/test_missing.py",
2328 "from nonexistent.module import X\n\ndef test_x():\n pass\n",
2329 &[],
2330 );
2331
2332 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2334 if let Some(mapping) = mapping {
2335 assert!(
2336 !mapping.test_files.contains(&r.test_path),
2337 "test_missing.py should NOT be mapped to models/real.py: {:?}",
2338 mapping.test_files
2339 );
2340 }
2341 }
2343
2344 #[test]
2348 fn py_abs_05_mixed_absolute_and_relative_imports() {
2349 let tmp = tempfile::tempdir().unwrap();
2353 let models_dir = tmp.path().join("models");
2354 let tests_dir = tmp.path().join("tests");
2355 std::fs::create_dir_all(&models_dir).unwrap();
2356 std::fs::create_dir_all(&tests_dir).unwrap();
2357
2358 let cars_py = models_dir.join("cars.py");
2359 std::fs::write(&cars_py, "class Car:\n pass\n").unwrap();
2360
2361 let helpers_py = tests_dir.join("helpers.py");
2362 std::fs::write(&helpers_py, "def setup():\n pass\n").unwrap();
2363
2364 let test_py = tests_dir.join("test_mixed.py");
2365 let test_source =
2366 "from models.cars import Car\nfrom .helpers import setup\n\ndef test_mixed():\n pass\n";
2367 std::fs::write(&test_py, test_source).unwrap();
2368
2369 let extractor = PythonExtractor::new();
2370 let cars_prod = cars_py.to_string_lossy().into_owned();
2371 let helpers_prod = helpers_py.to_string_lossy().into_owned();
2372 let test_path = test_py.to_string_lossy().into_owned();
2373
2374 let production_files = vec![cars_prod.clone(), helpers_prod.clone()];
2375 let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2376 .into_iter()
2377 .collect();
2378
2379 let result =
2381 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2382
2383 let cars_mapping = result.iter().find(|m| m.production_file == cars_prod);
2385 assert!(
2386 cars_mapping.is_some(),
2387 "models/cars.py not found in mappings: {:?}",
2388 result
2389 );
2390 let cars_m = cars_mapping.unwrap();
2391 assert!(
2392 cars_m.test_files.contains(&test_path),
2393 "test_mixed.py not mapped to models/cars.py via absolute import: {:?}",
2394 cars_m.test_files
2395 );
2396
2397 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
2399 assert!(
2400 helpers_mapping.is_none(),
2401 "tests/helpers.py should be excluded as test helper (Phase 20), but found in mappings: {:?}",
2402 helpers_mapping
2403 );
2404 }
2405
2406 #[test]
2410 fn py_rel_01_bare_two_dot_relative_import() {
2411 let r = run_import_test(
2414 "pkg/utils.py",
2415 "def helper():\n pass\n",
2416 "pkg/sub/test_thing.py",
2417 "from .. import utils\n\ndef test_thing():\n pass\n",
2418 &[],
2419 );
2420
2421 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2423 assert!(
2424 mapping.is_some(),
2425 "pkg/utils.py not found in mappings: {:?}",
2426 r.mappings
2427 );
2428 let mapping = mapping.unwrap();
2429 assert!(
2430 mapping.test_files.contains(&r.test_path),
2431 "test_thing.py not in test_files for pkg/utils.py via bare two-dot import: {:?}",
2432 mapping.test_files
2433 );
2434 }
2435}
2436
2437const ROUTE_DECORATOR_QUERY: &str = include_str!("../queries/route_decorator.scm");
2442static ROUTE_DECORATOR_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2443
2444const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
2445
2446#[derive(Debug, Clone, PartialEq)]
2448pub struct Route {
2449 pub http_method: String,
2450 pub path: String,
2451 pub handler_name: String,
2452 pub file: String,
2453}
2454
2455fn collect_router_prefixes(
2458 source_bytes: &[u8],
2459 tree: &tree_sitter::Tree,
2460) -> HashMap<String, String> {
2461 let mut prefixes = HashMap::new();
2462
2463 let root = tree.root_node();
2465 let mut stack = vec![root];
2466
2467 while let Some(node) = stack.pop() {
2468 if node.kind() == "assignment" {
2469 let left = node.child_by_field_name("left");
2470 let right = node.child_by_field_name("right");
2471
2472 if let (Some(left_node), Some(right_node)) = (left, right) {
2473 if left_node.kind() == "identifier" && right_node.kind() == "call" {
2474 let var_name = left_node.utf8_text(source_bytes).unwrap_or("").to_string();
2475
2476 let fn_node = right_node.child_by_field_name("function");
2478 let is_api_router = fn_node
2479 .and_then(|f| f.utf8_text(source_bytes).ok())
2480 .map(|name| name == "APIRouter")
2481 .unwrap_or(false);
2482
2483 if is_api_router {
2484 let args_node = right_node.child_by_field_name("arguments");
2486 if let Some(args) = args_node {
2487 let mut args_cursor = args.walk();
2488 for arg in args.named_children(&mut args_cursor) {
2489 if arg.kind() == "keyword_argument" {
2490 let kw_name = arg
2491 .child_by_field_name("name")
2492 .and_then(|n| n.utf8_text(source_bytes).ok())
2493 .unwrap_or("");
2494 if kw_name == "prefix" {
2495 if let Some(val) = arg.child_by_field_name("value") {
2496 if val.kind() == "string" {
2497 let raw = val.utf8_text(source_bytes).unwrap_or("");
2498 let prefix = strip_string_quotes(raw);
2499 prefixes.insert(var_name.clone(), prefix);
2500 }
2501 }
2502 }
2503 }
2504 }
2505 }
2506 prefixes.entry(var_name).or_default();
2508 }
2509 }
2510 }
2511 }
2512
2513 let mut w = node.walk();
2515 let children: Vec<_> = node.named_children(&mut w).collect();
2516 for child in children.into_iter().rev() {
2517 stack.push(child);
2518 }
2519 }
2520
2521 prefixes
2522}
2523
2524fn strip_string_quotes(raw: &str) -> String {
2530 let raw = raw.trim_start_matches(|c: char| "rRbBfFuU".contains(c));
2533 for q in &[r#"""""#, "'''"] {
2535 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2536 return inner.to_string();
2537 }
2538 }
2539 for q in &["\"", "'"] {
2541 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2542 return inner.to_string();
2543 }
2544 }
2545 raw.to_string()
2546}
2547
2548pub fn extract_routes(source: &str, file_path: &str) -> Vec<Route> {
2550 if source.is_empty() {
2551 return Vec::new();
2552 }
2553
2554 let mut parser = PythonExtractor::parser();
2555 let tree = match parser.parse(source, None) {
2556 Some(t) => t,
2557 None => return Vec::new(),
2558 };
2559 let source_bytes = source.as_bytes();
2560
2561 let router_prefixes = collect_router_prefixes(source_bytes, &tree);
2563
2564 let query = cached_query(&ROUTE_DECORATOR_QUERY_CACHE, ROUTE_DECORATOR_QUERY);
2566
2567 let obj_idx = query.capture_index_for_name("route.object");
2568 let method_idx = query.capture_index_for_name("route.method");
2569 let path_idx = query.capture_index_for_name("route.path");
2570 let handler_idx = query.capture_index_for_name("route.handler");
2571
2572 let mut cursor = QueryCursor::new();
2573 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2574
2575 let mut routes = Vec::new();
2576 let mut seen = HashSet::new();
2577
2578 while let Some(m) = matches.next() {
2579 let mut obj: Option<String> = None;
2580 let mut method: Option<String> = None;
2581 let mut path_raw: Option<String> = None;
2582 let mut path_is_string = false;
2583 let mut handler: Option<String> = None;
2584
2585 for cap in m.captures {
2586 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
2587 if obj_idx == Some(cap.index) {
2588 obj = Some(text);
2589 } else if method_idx == Some(cap.index) {
2590 method = Some(text);
2591 } else if path_idx == Some(cap.index) {
2592 path_is_string = cap.node.kind() == "string";
2594 path_raw = Some(text);
2595 } else if handler_idx == Some(cap.index) {
2596 handler = Some(text);
2597 }
2598 }
2599
2600 let (obj, method, handler) = match (obj, method, handler) {
2601 (Some(o), Some(m), Some(h)) => (o, m, h),
2602 _ => continue,
2603 };
2604
2605 if !HTTP_METHODS.contains(&method.as_str()) {
2607 continue;
2608 }
2609
2610 let sub_path = match path_raw {
2612 Some(ref raw) if path_is_string => strip_string_quotes(raw),
2613 Some(_) => "<dynamic>".to_string(),
2614 None => "<dynamic>".to_string(),
2615 };
2616
2617 let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
2619 let full_path = if prefix.is_empty() {
2620 sub_path
2621 } else {
2622 format!("{prefix}{sub_path}")
2623 };
2624
2625 let key = (method.clone(), full_path.clone(), handler.clone());
2627 if !seen.insert(key) {
2628 continue;
2629 }
2630
2631 routes.push(Route {
2632 http_method: method.to_uppercase(),
2633 path: full_path,
2634 handler_name: handler,
2635 file: file_path.to_string(),
2636 });
2637 }
2638
2639 routes
2640}
2641
2642#[cfg(test)]
2647mod route_tests {
2648 use super::*;
2649
2650 #[test]
2652 fn fa_rt_01_basic_app_get_route() {
2653 let source = r#"
2655from fastapi import FastAPI
2656app = FastAPI()
2657
2658@app.get("/users")
2659def read_users():
2660 return []
2661"#;
2662
2663 let routes = extract_routes(source, "main.py");
2665
2666 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2668 assert_eq!(routes[0].http_method, "GET");
2669 assert_eq!(routes[0].path, "/users");
2670 assert_eq!(routes[0].handler_name, "read_users");
2671 }
2672
2673 #[test]
2675 fn fa_rt_02_multiple_http_methods() {
2676 let source = r#"
2678from fastapi import FastAPI
2679app = FastAPI()
2680
2681@app.get("/items")
2682def list_items():
2683 return []
2684
2685@app.post("/items")
2686def create_item():
2687 return {}
2688
2689@app.put("/items/{item_id}")
2690def update_item(item_id: int):
2691 return {}
2692
2693@app.delete("/items/{item_id}")
2694def delete_item(item_id: int):
2695 return {}
2696"#;
2697
2698 let routes = extract_routes(source, "main.py");
2700
2701 assert_eq!(routes.len(), 4, "expected 4 routes, got {:?}", routes);
2703 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
2704 assert!(methods.contains(&"GET"), "missing GET");
2705 assert!(methods.contains(&"POST"), "missing POST");
2706 assert!(methods.contains(&"PUT"), "missing PUT");
2707 assert!(methods.contains(&"DELETE"), "missing DELETE");
2708 }
2709
2710 #[test]
2712 fn fa_rt_03_path_parameter() {
2713 let source = r#"
2715from fastapi import FastAPI
2716app = FastAPI()
2717
2718@app.get("/items/{item_id}")
2719def read_item(item_id: int):
2720 return {}
2721"#;
2722
2723 let routes = extract_routes(source, "main.py");
2725
2726 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2728 assert_eq!(routes[0].path, "/items/{item_id}");
2729 }
2730
2731 #[test]
2733 fn fa_rt_04_router_get_with_prefix() {
2734 let source = r#"
2736from fastapi import APIRouter
2737
2738router = APIRouter(prefix="/items")
2739
2740@router.get("/{item_id}")
2741def read_item(item_id: int):
2742 return {}
2743"#;
2744
2745 let routes = extract_routes(source, "routes.py");
2747
2748 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2750 assert_eq!(
2751 routes[0].path, "/items/{item_id}",
2752 "expected prefix-resolved path"
2753 );
2754 }
2755
2756 #[test]
2758 fn fa_rt_05_router_get_without_prefix() {
2759 let source = r#"
2761from fastapi import APIRouter
2762
2763router = APIRouter()
2764
2765@router.get("/health")
2766def health_check():
2767 return {"status": "ok"}
2768"#;
2769
2770 let routes = extract_routes(source, "routes.py");
2772
2773 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2775 assert_eq!(routes[0].path, "/health");
2776 }
2777
2778 #[test]
2780 fn fa_rt_06_non_route_decorator_ignored() {
2781 let source = r#"
2783import pytest
2784
2785@pytest.fixture
2786def client():
2787 return None
2788
2789class MyClass:
2790 @staticmethod
2791 def helper():
2792 pass
2793"#;
2794
2795 let routes = extract_routes(source, "main.py");
2797
2798 assert!(
2800 routes.is_empty(),
2801 "expected no routes for non-route decorators, got {:?}",
2802 routes
2803 );
2804 }
2805
2806 #[test]
2808 fn fa_rt_07_dynamic_path_non_literal() {
2809 let source = r#"
2811from fastapi import FastAPI
2812app = FastAPI()
2813
2814ROUTE_PATH = "/dynamic"
2815
2816@app.get(ROUTE_PATH)
2817def dynamic_route():
2818 return {}
2819"#;
2820
2821 let routes = extract_routes(source, "main.py");
2823
2824 assert_eq!(
2826 routes.len(),
2827 1,
2828 "expected 1 route for dynamic path, got {:?}",
2829 routes
2830 );
2831 assert_eq!(
2832 routes[0].path, "<dynamic>",
2833 "expected <dynamic> for non-literal path argument"
2834 );
2835 }
2836
2837 #[test]
2839 fn fa_rt_08_empty_source() {
2840 let source = "";
2842
2843 let routes = extract_routes(source, "main.py");
2845
2846 assert!(routes.is_empty(), "expected empty Vec for empty source");
2848 }
2849
2850 #[test]
2852 fn fa_rt_09_async_def_handler() {
2853 let source = r#"
2855from fastapi import FastAPI
2856app = FastAPI()
2857
2858@app.get("/")
2859async def root():
2860 return {"message": "hello"}
2861"#;
2862
2863 let routes = extract_routes(source, "main.py");
2865
2866 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2868 assert_eq!(
2869 routes[0].handler_name, "root",
2870 "async def should produce handler_name = 'root'"
2871 );
2872 }
2873
2874 #[test]
2876 fn fa_rt_10_multiple_decorators_on_same_function() {
2877 let source = r#"
2879from fastapi import FastAPI
2880app = FastAPI()
2881
2882def require_auth(func):
2883 return func
2884
2885@app.get("/")
2886@require_auth
2887def root():
2888 return {}
2889"#;
2890
2891 let routes = extract_routes(source, "main.py");
2893
2894 assert_eq!(
2896 routes.len(),
2897 1,
2898 "expected exactly 1 route (non-route decorators ignored), got {:?}",
2899 routes
2900 );
2901 assert_eq!(routes[0].http_method, "GET");
2902 assert_eq!(routes[0].path, "/");
2903 assert_eq!(routes[0].handler_name, "root");
2904 }
2905}
2906
2907const DJANGO_URL_PATTERN_QUERY: &str = include_str!("../queries/django_url_pattern.scm");
2912static DJANGO_URL_PATTERN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2913
2914static DJANGO_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
2915static DJANGO_RE_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
2916
2917const HTTP_METHOD_ANY: &str = "ANY";
2918
2919pub fn normalize_django_path(path: &str) -> String {
2923 let re = DJANGO_PATH_RE
2924 .get_or_init(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").expect("invalid regex"));
2925 re.replace_all(path, ":$1").into_owned()
2926}
2927
2928pub fn normalize_re_path(path: &str) -> String {
2931 let s = path.strip_prefix('^').unwrap_or(path);
2933 let s = s.strip_suffix('$').unwrap_or(s);
2935 let re = DJANGO_RE_PATH_RE
2941 .get_or_init(|| regex::Regex::new(r"\(\?P<(\w+)>[^)]*\)").expect("invalid regex"));
2942 re.replace_all(s, ":$1").into_owned()
2943}
2944
2945pub fn extract_django_routes(source: &str, file_path: &str) -> Vec<Route> {
2947 if source.is_empty() {
2948 return Vec::new();
2949 }
2950
2951 let mut parser = PythonExtractor::parser();
2952 let tree = match parser.parse(source, None) {
2953 Some(t) => t,
2954 None => return Vec::new(),
2955 };
2956 let source_bytes = source.as_bytes();
2957
2958 let query = cached_query(&DJANGO_URL_PATTERN_QUERY_CACHE, DJANGO_URL_PATTERN_QUERY);
2959
2960 let func_idx = query.capture_index_for_name("django.func");
2961 let path_idx = query.capture_index_for_name("django.path");
2962 let handler_idx = query.capture_index_for_name("django.handler");
2963
2964 let mut cursor = QueryCursor::new();
2965 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2966
2967 let mut routes = Vec::new();
2968 let mut seen = HashSet::new();
2969
2970 while let Some(m) = matches.next() {
2971 let mut func: Option<String> = None;
2972 let mut path_raw: Option<String> = None;
2973 let mut handler: Option<String> = None;
2974
2975 for cap in m.captures {
2976 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
2977 if func_idx == Some(cap.index) {
2978 func = Some(text);
2979 } else if path_idx == Some(cap.index) {
2980 path_raw = Some(text);
2981 } else if handler_idx == Some(cap.index) {
2982 handler = Some(text);
2983 }
2984 }
2985
2986 let (func, path_raw, handler) = match (func, path_raw, handler) {
2987 (Some(f), Some(p), Some(h)) => (f, p, h),
2988 _ => continue,
2989 };
2990
2991 let raw_path = strip_string_quotes(&path_raw);
2992 let normalized = match func.as_str() {
2993 "re_path" => normalize_re_path(&raw_path),
2994 _ => normalize_django_path(&raw_path),
2995 };
2996
2997 let key = (
2999 HTTP_METHOD_ANY.to_string(),
3000 normalized.clone(),
3001 handler.clone(),
3002 );
3003 if !seen.insert(key) {
3004 continue;
3005 }
3006
3007 routes.push(Route {
3008 http_method: HTTP_METHOD_ANY.to_string(),
3009 path: normalized,
3010 handler_name: handler,
3011 file: file_path.to_string(),
3012 });
3013 }
3014
3015 routes
3016}
3017
3018#[cfg(test)]
3023mod django_route_tests {
3024 use super::*;
3025
3026 #[test]
3032 fn dj_np_01_typed_parameter() {
3033 let result = normalize_django_path("users/<int:pk>/");
3037 assert_eq!(result, "users/:pk/");
3038 }
3039
3040 #[test]
3042 fn dj_np_02_untyped_parameter() {
3043 let result = normalize_django_path("users/<pk>/");
3047 assert_eq!(result, "users/:pk/");
3048 }
3049
3050 #[test]
3052 fn dj_np_03_multiple_parameters() {
3053 let result = normalize_django_path("posts/<slug:slug>/comments/<int:id>/");
3057 assert_eq!(result, "posts/:slug/comments/:id/");
3058 }
3059
3060 #[test]
3062 fn dj_np_04_no_parameters() {
3063 let result = normalize_django_path("users/");
3067 assert_eq!(result, "users/");
3068 }
3069
3070 #[test]
3076 fn dj_nr_01_single_named_group() {
3077 let result = normalize_re_path("^articles/(?P<year>[0-9]{4})/$");
3081 assert_eq!(result, "articles/:year/");
3082 }
3083
3084 #[test]
3086 fn dj_nr_02_multiple_named_groups() {
3087 let result = normalize_re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$");
3091 assert_eq!(result, ":year/:month/");
3092 }
3093
3094 #[test]
3096 fn dj_nr_03_no_named_groups() {
3097 let result = normalize_re_path("^users/$");
3101 assert_eq!(result, "users/");
3102 }
3103
3104 #[test]
3106 fn dj_nr_04_character_class_caret_preserved() {
3107 let result = normalize_re_path("^items/[^/]+/$");
3111 assert_eq!(result, "items/[^/]+/");
3112 }
3113
3114 #[test]
3120 fn dj_rt_01_basic_path_attribute_handler() {
3121 let source = r#"
3123from django.urls import path
3124from . import views
3125
3126urlpatterns = [
3127 path("users/", views.user_list),
3128]
3129"#;
3130 let routes = extract_django_routes(source, "urls.py");
3132
3133 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3135 assert_eq!(routes[0].http_method, "ANY");
3136 assert_eq!(routes[0].path, "users/");
3137 assert_eq!(routes[0].handler_name, "user_list");
3138 }
3139
3140 #[test]
3142 fn dj_rt_02_path_direct_import_handler() {
3143 let source = r#"
3145from django.urls import path
3146from .views import user_list
3147
3148urlpatterns = [
3149 path("users/", user_list),
3150]
3151"#;
3152 let routes = extract_django_routes(source, "urls.py");
3154
3155 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3157 assert_eq!(routes[0].http_method, "ANY");
3158 assert_eq!(routes[0].path, "users/");
3159 assert_eq!(routes[0].handler_name, "user_list");
3160 }
3161
3162 #[test]
3164 fn dj_rt_03_path_typed_parameter() {
3165 let source = r#"
3167from django.urls import path
3168from . import views
3169
3170urlpatterns = [
3171 path("users/<int:pk>/", views.user_detail),
3172]
3173"#;
3174 let routes = extract_django_routes(source, "urls.py");
3176
3177 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3179 assert_eq!(routes[0].path, "users/:pk/");
3180 }
3181
3182 #[test]
3184 fn dj_rt_04_path_untyped_parameter() {
3185 let source = r#"
3187from django.urls import path
3188from . import views
3189
3190urlpatterns = [
3191 path("users/<pk>/", views.user_detail),
3192]
3193"#;
3194 let routes = extract_django_routes(source, "urls.py");
3196
3197 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3199 assert_eq!(routes[0].path, "users/:pk/");
3200 }
3201
3202 #[test]
3204 fn dj_rt_05_re_path_named_group() {
3205 let source = r#"
3207from django.urls import re_path
3208from . import views
3209
3210urlpatterns = [
3211 re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive),
3212]
3213"#;
3214 let routes = extract_django_routes(source, "urls.py");
3216
3217 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3219 assert_eq!(routes[0].path, "articles/:year/");
3220 }
3221
3222 #[test]
3224 fn dj_rt_06_multiple_routes() {
3225 let source = r#"
3227from django.urls import path
3228from . import views
3229
3230urlpatterns = [
3231 path("users/", views.user_list),
3232 path("users/<int:pk>/", views.user_detail),
3233 path("about/", views.about),
3234]
3235"#;
3236 let routes = extract_django_routes(source, "urls.py");
3238
3239 assert_eq!(routes.len(), 3, "expected 3 routes, got {:?}", routes);
3241 for r in &routes {
3242 assert_eq!(r.http_method, "ANY", "expected method ANY for {:?}", r);
3243 }
3244 }
3245
3246 #[test]
3248 fn dj_rt_07_path_with_name_kwarg() {
3249 let source = r#"
3251from django.urls import path
3252from . import views
3253
3254urlpatterns = [
3255 path("login/", views.login_view, name="login"),
3256]
3257"#;
3258 let routes = extract_django_routes(source, "urls.py");
3260
3261 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3263 assert_eq!(routes[0].handler_name, "login_view");
3264 }
3265
3266 #[test]
3268 fn dj_rt_08_empty_source() {
3269 let routes = extract_django_routes("", "urls.py");
3272
3273 assert!(routes.is_empty(), "expected empty Vec for empty source");
3275 }
3276
3277 #[test]
3279 fn dj_rt_09_no_path_calls() {
3280 let source = r#"
3282from django.db import models
3283
3284class User(models.Model):
3285 name = models.CharField(max_length=100)
3286"#;
3287 let routes = extract_django_routes(source, "models.py");
3289
3290 assert!(
3292 routes.is_empty(),
3293 "expected empty Vec for non-URL source, got {:?}",
3294 routes
3295 );
3296 }
3297
3298 #[test]
3300 fn dj_rt_10_deduplication() {
3301 let source = r#"
3303from django.urls import path
3304from . import views
3305
3306urlpatterns = [
3307 path("users/", views.user_list),
3308 path("users/", views.user_list),
3309]
3310"#;
3311 let routes = extract_django_routes(source, "urls.py");
3313
3314 assert_eq!(
3316 routes.len(),
3317 1,
3318 "expected 1 route after dedup, got {:?}",
3319 routes
3320 );
3321 }
3322
3323 #[test]
3325 fn dj_rt_11_include_is_ignored() {
3326 let source = r#"
3328from django.urls import path, include
3329
3330urlpatterns = [
3331 path("api/", include("myapp.urls")),
3332]
3333"#;
3334 let routes = extract_django_routes(source, "urls.py");
3336
3337 assert!(
3339 routes.is_empty(),
3340 "expected empty Vec for include()-only urlpatterns, got {:?}",
3341 routes
3342 );
3343 }
3344
3345 #[test]
3347 fn dj_rt_12_multiple_path_parameters() {
3348 let source = r#"
3350from django.urls import path
3351from . import views
3352
3353urlpatterns = [
3354 path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail),
3355]
3356"#;
3357 let routes = extract_django_routes(source, "urls.py");
3359
3360 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3362 assert_eq!(routes[0].path, "posts/:slug/comments/:id/");
3363 }
3364
3365 #[test]
3367 fn dj_rt_13_re_path_multiple_named_groups() {
3368 let source = r#"
3370from django.urls import re_path
3371from . import views
3372
3373urlpatterns = [
3374 re_path(r"^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive),
3375]
3376"#;
3377 let routes = extract_django_routes(source, "urls.py");
3379
3380 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3382 assert_eq!(routes[0].path, ":year/:month/");
3383 }
3384
3385 #[test]
3391 fn dj_rt_e2e_01_observe_django_routes_coverage() {
3392 use tempfile::TempDir;
3393
3394 let dir = TempDir::new().unwrap();
3396 let urls_py = dir.path().join("urls.py");
3397 let test_urls_py = dir.path().join("test_urls.py");
3398
3399 std::fs::write(
3400 &urls_py,
3401 r#"from django.urls import path
3402from . import views
3403
3404urlpatterns = [
3405 path("users/", views.user_list),
3406 path("users/<int:pk>/", views.user_detail),
3407]
3408"#,
3409 )
3410 .unwrap();
3411
3412 std::fs::write(
3413 &test_urls_py,
3414 r#"def test_user_list():
3415 pass
3416
3417def test_user_detail():
3418 pass
3419"#,
3420 )
3421 .unwrap();
3422
3423 let urls_source = std::fs::read_to_string(&urls_py).unwrap();
3425 let urls_path = urls_py.to_string_lossy().into_owned();
3426
3427 let routes = extract_django_routes(&urls_source, &urls_path);
3428
3429 assert_eq!(
3431 routes.len(),
3432 2,
3433 "expected 2 routes extracted from urls.py, got {:?}",
3434 routes
3435 );
3436
3437 for r in &routes {
3439 assert_eq!(r.http_method, "ANY", "expected method ANY, got {:?}", r);
3440 }
3441 }
3442
3443 #[test]
3448 fn py_import_04_e2e_bare_import_wildcard_barrel_mapped() {
3449 use tempfile::TempDir;
3450
3451 let dir = TempDir::new().unwrap();
3454 let pkg = dir.path().join("pkg");
3455 std::fs::create_dir_all(&pkg).unwrap();
3456
3457 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
3458 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
3459
3460 let tests_dir = dir.path().join("tests");
3461 std::fs::create_dir_all(&tests_dir).unwrap();
3462 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
3463 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3464
3465 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3466 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3467
3468 let extractor = PythonExtractor::new();
3469 let production_files = vec![module_path.clone()];
3470 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3471 .into_iter()
3472 .collect();
3473
3474 let result =
3476 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3477
3478 let mapping = result.iter().find(|m| m.production_file == module_path);
3480 assert!(
3481 mapping.is_some(),
3482 "module.py not mapped; bare import + wildcard barrel should resolve. mappings={:?}",
3483 result
3484 );
3485 let mapping = mapping.unwrap();
3486 assert!(
3487 mapping.test_files.contains(&test_path),
3488 "test_foo.py not in test_files for module.py: {:?}",
3489 mapping.test_files
3490 );
3491 }
3492
3493 #[test]
3498 fn py_import_05_e2e_bare_import_named_barrel_mapped() {
3499 use tempfile::TempDir;
3500
3501 let dir = TempDir::new().unwrap();
3504 let pkg = dir.path().join("pkg");
3505 std::fs::create_dir_all(&pkg).unwrap();
3506
3507 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
3508 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
3509
3510 let tests_dir = dir.path().join("tests");
3511 std::fs::create_dir_all(&tests_dir).unwrap();
3512 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
3513 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3514
3515 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3516 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3517
3518 let extractor = PythonExtractor::new();
3519 let production_files = vec![module_path.clone()];
3520 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3521 .into_iter()
3522 .collect();
3523
3524 let result =
3526 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3527
3528 let mapping = result.iter().find(|m| m.production_file == module_path);
3530 assert!(
3531 mapping.is_some(),
3532 "module.py not mapped; bare import + named barrel should resolve. mappings={:?}",
3533 result
3534 );
3535 let mapping = mapping.unwrap();
3536 assert!(
3537 mapping.test_files.contains(&test_path),
3538 "test_foo.py not in test_files for module.py: {:?}",
3539 mapping.test_files
3540 );
3541 }
3542
3543 #[test]
3548 fn py_attr_01_bare_import_single_attribute() {
3549 let source = "import httpx\nhttpx.Client()\n";
3551
3552 let extractor = PythonExtractor::new();
3554 let result = extractor.extract_all_import_specifiers(source);
3555
3556 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3558 assert!(entry.is_some(), "httpx not found in {:?}", result);
3559 let (_, symbols) = entry.unwrap();
3560 assert_eq!(
3561 symbols,
3562 &vec!["Client".to_string()],
3563 "expected [\"Client\"] for bare import with attribute access, got {:?}",
3564 symbols
3565 );
3566 }
3567
3568 #[test]
3573 fn py_attr_02_bare_import_multiple_attributes() {
3574 let source = "import httpx\nhttpx.Client()\nhttpx.get()\n";
3576
3577 let extractor = PythonExtractor::new();
3579 let result = extractor.extract_all_import_specifiers(source);
3580
3581 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3583 assert!(entry.is_some(), "httpx not found in {:?}", result);
3584 let (_, symbols) = entry.unwrap();
3585 assert!(
3586 symbols.contains(&"Client".to_string()),
3587 "Client not in symbols: {:?}",
3588 symbols
3589 );
3590 assert!(
3591 symbols.contains(&"get".to_string()),
3592 "get not in symbols: {:?}",
3593 symbols
3594 );
3595 }
3596
3597 #[test]
3602 fn py_attr_03_bare_import_deduplicated_attributes() {
3603 let source = "import httpx\nhttpx.Client()\nhttpx.Client()\n";
3605
3606 let extractor = PythonExtractor::new();
3608 let result = extractor.extract_all_import_specifiers(source);
3609
3610 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3612 assert!(entry.is_some(), "httpx not found in {:?}", result);
3613 let (_, symbols) = entry.unwrap();
3614 assert_eq!(
3615 symbols,
3616 &vec!["Client".to_string()],
3617 "expected [\"Client\"] with deduplication, got {:?}",
3618 symbols
3619 );
3620 }
3621
3622 #[test]
3632 fn py_attr_04_bare_import_no_attribute_fallback() {
3633 let source = "import httpx\n";
3635
3636 let extractor = PythonExtractor::new();
3638 let result = extractor.extract_all_import_specifiers(source);
3639
3640 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3642 assert!(
3643 entry.is_some(),
3644 "httpx not found in {:?}; bare import without attribute access should be included",
3645 result
3646 );
3647 let (_, symbols) = entry.unwrap();
3648 assert!(
3649 symbols.is_empty(),
3650 "expected empty symbols (fallback) for bare import with no attribute access, got {:?}",
3651 symbols
3652 );
3653 }
3654
3655 #[test]
3666 fn py_attr_05_from_import_regression() {
3667 let source = "from httpx import Client\n";
3669
3670 let extractor = PythonExtractor::new();
3672 let result = extractor.extract_all_import_specifiers(source);
3673
3674 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3676 assert!(entry.is_some(), "httpx not found in {:?}", result);
3677 let (_, symbols) = entry.unwrap();
3678 assert!(
3679 symbols.contains(&"Client".to_string()),
3680 "Client not in symbols: {:?}",
3681 symbols
3682 );
3683 }
3684
3685 #[test]
3691 fn py_attr_06_e2e_attribute_access_narrows_barrel_mapping() {
3692 use tempfile::TempDir;
3693
3694 let dir = TempDir::new().unwrap();
3700 let pkg = dir.path().join("pkg");
3701 std::fs::create_dir_all(&pkg).unwrap();
3702
3703 std::fs::write(
3704 pkg.join("__init__.py"),
3705 "from .mod import Foo\nfrom .bar import Bar\n",
3706 )
3707 .unwrap();
3708 std::fs::write(pkg.join("mod.py"), "def Foo(): pass\n").unwrap();
3709 std::fs::write(pkg.join("bar.py"), "def Bar(): pass\n").unwrap();
3710
3711 let tests_dir = dir.path().join("tests");
3712 std::fs::create_dir_all(&tests_dir).unwrap();
3713 let test_content = "import pkg\npkg.Foo()\n";
3715 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3716
3717 let mod_path = pkg.join("mod.py").to_string_lossy().into_owned();
3718 let bar_path = pkg.join("bar.py").to_string_lossy().into_owned();
3719 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3720
3721 let extractor = PythonExtractor::new();
3722 let production_files = vec![mod_path.clone(), bar_path.clone()];
3723 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3724 .into_iter()
3725 .collect();
3726
3727 let result =
3729 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3730
3731 let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
3733 assert!(
3734 mod_mapping.is_some(),
3735 "mod.py not mapped; pkg.Foo() should resolve to mod.py via barrel. mappings={:?}",
3736 result
3737 );
3738 assert!(
3739 mod_mapping.unwrap().test_files.contains(&test_path),
3740 "test_foo.py not in test_files for mod.py: {:?}",
3741 mod_mapping.unwrap().test_files
3742 );
3743
3744 let bar_mapping = result.iter().find(|m| m.production_file == bar_path);
3746 let bar_not_mapped = bar_mapping
3747 .map(|m| !m.test_files.contains(&test_path))
3748 .unwrap_or(true);
3749 assert!(
3750 bar_not_mapped,
3751 "bar.py should NOT be mapped for test_foo.py (pkg.Bar() is not accessed), but got: {:?}",
3752 bar_mapping
3753 );
3754 }
3755
3756 #[test]
3766 fn py_l1x_01_stem_only_fallback_cross_directory() {
3767 use tempfile::TempDir;
3768
3769 let dir = TempDir::new().unwrap();
3774 let pkg = dir.path().join("pkg");
3775 std::fs::create_dir_all(&pkg).unwrap();
3776 let tests_dir = dir.path().join("tests");
3777 std::fs::create_dir_all(&tests_dir).unwrap();
3778
3779 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
3780
3781 let test_content = "def test_client():\n pass\n";
3783 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
3784
3785 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
3786 let test_path = tests_dir
3787 .join("test_client.py")
3788 .to_string_lossy()
3789 .into_owned();
3790
3791 let extractor = PythonExtractor::new();
3792 let production_files = vec![client_path.clone()];
3793 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3794 .into_iter()
3795 .collect();
3796
3797 let result =
3799 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3800
3801 let mapping = result.iter().find(|m| m.production_file == client_path);
3803 assert!(
3804 mapping.is_some(),
3805 "pkg/_client.py not mapped; stem-only fallback should match across directories. mappings={:?}",
3806 result
3807 );
3808 let mapping = mapping.unwrap();
3809 assert!(
3810 mapping.test_files.contains(&test_path),
3811 "test_client.py not in test_files for pkg/_client.py: {:?}",
3812 mapping.test_files
3813 );
3814 }
3815
3816 #[test]
3824 fn py_l1x_02_stem_only_underscore_prefix_prod() {
3825 use tempfile::TempDir;
3826
3827 let dir = TempDir::new().unwrap();
3829 let pkg = dir.path().join("pkg");
3830 std::fs::create_dir_all(&pkg).unwrap();
3831 let tests_dir = dir.path().join("tests");
3832 std::fs::create_dir_all(&tests_dir).unwrap();
3833
3834 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
3835
3836 let test_content = "def test_decode():\n pass\n";
3838 std::fs::write(tests_dir.join("test_decoders.py"), test_content).unwrap();
3839
3840 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
3841 let test_path = tests_dir
3842 .join("test_decoders.py")
3843 .to_string_lossy()
3844 .into_owned();
3845
3846 let extractor = PythonExtractor::new();
3847 let production_files = vec![decoders_path.clone()];
3848 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3849 .into_iter()
3850 .collect();
3851
3852 let result =
3854 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3855
3856 let mapping = result.iter().find(|m| m.production_file == decoders_path);
3859 assert!(
3860 mapping.is_some(),
3861 "pkg/_decoders.py not mapped; stem-only fallback should strip _ prefix and match. mappings={:?}",
3862 result
3863 );
3864 let mapping = mapping.unwrap();
3865 assert!(
3866 mapping.test_files.contains(&test_path),
3867 "test_decoders.py not in test_files for pkg/_decoders.py: {:?}",
3868 mapping.test_files
3869 );
3870 }
3871
3872 #[test]
3879 fn py_l1x_03_stem_only_subdirectory_prod() {
3880 use tempfile::TempDir;
3881
3882 let dir = TempDir::new().unwrap();
3884 let transports = dir.path().join("pkg").join("transports");
3885 std::fs::create_dir_all(&transports).unwrap();
3886 let tests_dir = dir.path().join("tests");
3887 std::fs::create_dir_all(&tests_dir).unwrap();
3888
3889 std::fs::write(
3890 transports.join("asgi.py"),
3891 "class ASGITransport:\n pass\n",
3892 )
3893 .unwrap();
3894
3895 let test_content = "def test_asgi_transport():\n pass\n";
3897 std::fs::write(tests_dir.join("test_asgi.py"), test_content).unwrap();
3898
3899 let asgi_path = transports.join("asgi.py").to_string_lossy().into_owned();
3900 let test_path = tests_dir
3901 .join("test_asgi.py")
3902 .to_string_lossy()
3903 .into_owned();
3904
3905 let extractor = PythonExtractor::new();
3906 let production_files = vec![asgi_path.clone()];
3907 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3908 .into_iter()
3909 .collect();
3910
3911 let result =
3913 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3914
3915 let mapping = result.iter().find(|m| m.production_file == asgi_path);
3918 assert!(
3919 mapping.is_some(),
3920 "pkg/transports/asgi.py not mapped; stem 'asgi' should match across directory depth. mappings={:?}",
3921 result
3922 );
3923 let mapping = mapping.unwrap();
3924 assert!(
3925 mapping.test_files.contains(&test_path),
3926 "test_asgi.py not in test_files for pkg/transports/asgi.py: {:?}",
3927 mapping.test_files
3928 );
3929 }
3930
3931 #[test]
3938 fn py_l1x_04_stem_ambiguity_maps_to_all() {
3939 use tempfile::TempDir;
3940
3941 let dir = TempDir::new().unwrap();
3944 let pkg = dir.path().join("pkg");
3945 let pkg_aio = pkg.join("aio");
3946 std::fs::create_dir_all(&pkg).unwrap();
3947 std::fs::create_dir_all(&pkg_aio).unwrap();
3948 let tests_dir = dir.path().join("tests");
3949 std::fs::create_dir_all(&tests_dir).unwrap();
3950
3951 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
3952 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
3953
3954 let test_content = "def test_client():\n pass\n";
3956 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
3957
3958 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
3959 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
3960 let test_path = tests_dir
3961 .join("test_client.py")
3962 .to_string_lossy()
3963 .into_owned();
3964
3965 let extractor = PythonExtractor::new();
3966 let production_files = vec![client_path.clone(), aio_client_path.clone()];
3967 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3968 .into_iter()
3969 .collect();
3970
3971 let result =
3973 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3974
3975 let client_mapped = result
3978 .iter()
3979 .find(|m| m.production_file == client_path)
3980 .map(|m| m.test_files.contains(&test_path))
3981 .unwrap_or(false);
3982 assert!(
3983 client_mapped,
3984 "test_client.py should be mapped to pkg/client.py (stem ambiguity -> all). mappings={:?}",
3985 result
3986 );
3987
3988 let aio_mapped = result
3989 .iter()
3990 .find(|m| m.production_file == aio_client_path)
3991 .map(|m| m.test_files.contains(&test_path))
3992 .unwrap_or(false);
3993 assert!(
3994 aio_mapped,
3995 "test_client.py should be mapped to pkg/aio/client.py (stem ambiguity -> all). mappings={:?}",
3996 result
3997 );
3998 }
3999
4000 #[test]
4008 fn py_l1x_05_l1_core_match_suppresses_fallback() {
4009 use tempfile::TempDir;
4010
4011 let dir = TempDir::new().unwrap();
4015 let pkg = dir.path().join("pkg");
4016 let svc = dir.path().join("svc");
4017 std::fs::create_dir_all(&pkg).unwrap();
4018 std::fs::create_dir_all(&svc).unwrap();
4019
4020 std::fs::write(svc.join("client.py"), "class Client:\n pass\n").unwrap();
4021 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4022
4023 let test_content = "def test_client():\n pass\n";
4025 std::fs::write(svc.join("test_client.py"), test_content).unwrap();
4026
4027 let svc_client_path = svc.join("client.py").to_string_lossy().into_owned();
4028 let pkg_client_path = pkg.join("client.py").to_string_lossy().into_owned();
4029 let test_path = svc.join("test_client.py").to_string_lossy().into_owned();
4030
4031 let extractor = PythonExtractor::new();
4032 let production_files = vec![svc_client_path.clone(), pkg_client_path.clone()];
4033 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4034 .into_iter()
4035 .collect();
4036
4037 let result =
4039 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4040
4041 let svc_client_mapped = result
4043 .iter()
4044 .find(|m| m.production_file == svc_client_path)
4045 .map(|m| m.test_files.contains(&test_path))
4046 .unwrap_or(false);
4047 assert!(
4048 svc_client_mapped,
4049 "test_client.py should be mapped to svc/client.py via L1 core. mappings={:?}",
4050 result
4051 );
4052
4053 let pkg_not_mapped = result
4055 .iter()
4056 .find(|m| m.production_file == pkg_client_path)
4057 .map(|m| !m.test_files.contains(&test_path))
4058 .unwrap_or(true);
4059 assert!(
4060 pkg_not_mapped,
4061 "pkg/client.py should NOT be mapped (L1 core match suppresses stem-only fallback). mappings={:?}",
4062 result
4063 );
4064 }
4065
4066 #[test]
4076 fn py_sup_01_barrel_suppression_l1_matched_no_barrel_fan_out() {
4077 use tempfile::TempDir;
4078
4079 let dir = TempDir::new().unwrap();
4083 let pkg = dir.path().join("pkg");
4084 std::fs::create_dir_all(&pkg).unwrap();
4085 let tests_dir = dir.path().join("tests");
4086 std::fs::create_dir_all(&tests_dir).unwrap();
4087
4088 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4089 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4090 std::fs::write(
4091 pkg.join("__init__.py"),
4092 "from ._client import Client\nfrom ._utils import format_url\n",
4093 )
4094 .unwrap();
4095
4096 let test_content = "import pkg\n\ndef test_client():\n pass\n";
4100 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4101
4102 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4103 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4104 let test_path = tests_dir
4105 .join("test_client.py")
4106 .to_string_lossy()
4107 .into_owned();
4108
4109 let extractor = PythonExtractor::new();
4110 let production_files = vec![client_path.clone(), utils_path.clone()];
4111 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4112 .into_iter()
4113 .collect();
4114
4115 let result =
4117 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4118
4119 let client_mapped = result
4121 .iter()
4122 .find(|m| m.production_file == client_path)
4123 .map(|m| m.test_files.contains(&test_path))
4124 .unwrap_or(false);
4125 assert!(
4126 client_mapped,
4127 "pkg/_client.py should be mapped via L1 stem-only. mappings={:?}",
4128 result
4129 );
4130
4131 let utils_not_mapped = result
4133 .iter()
4134 .find(|m| m.production_file == utils_path)
4135 .map(|m| !m.test_files.contains(&test_path))
4136 .unwrap_or(true);
4137 assert!(
4138 utils_not_mapped,
4139 "pkg/_utils.py should NOT be mapped (barrel suppression for L1-matched test_client.py). mappings={:?}",
4140 result
4141 );
4142 }
4143
4144 #[test]
4151 fn py_sup_02_barrel_suppression_direct_import_still_added() {
4152 use tempfile::TempDir;
4153
4154 let dir = TempDir::new().unwrap();
4160 let pkg = dir.path().join("pkg");
4161 std::fs::create_dir_all(&pkg).unwrap();
4162 let tests_dir = dir.path().join("tests");
4163 std::fs::create_dir_all(&tests_dir).unwrap();
4164
4165 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4166 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4167 std::fs::write(
4168 pkg.join("__init__.py"),
4169 "from ._client import Client\nfrom ._utils import format_url\n",
4170 )
4171 .unwrap();
4172
4173 let test_content =
4175 "import pkg\nfrom pkg._utils import format_url\n\ndef test_client():\n assert format_url('http://x')\n";
4176 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4177
4178 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4179 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4180 let test_path = tests_dir
4181 .join("test_client.py")
4182 .to_string_lossy()
4183 .into_owned();
4184
4185 let extractor = PythonExtractor::new();
4186 let production_files = vec![client_path.clone(), utils_path.clone()];
4187 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4188 .into_iter()
4189 .collect();
4190
4191 let result =
4193 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4194
4195 let utils_mapped = result
4197 .iter()
4198 .find(|m| m.production_file == utils_path)
4199 .map(|m| m.test_files.contains(&test_path))
4200 .unwrap_or(false);
4201 assert!(
4202 utils_mapped,
4203 "pkg/_utils.py should be mapped via direct import (not barrel). mappings={:?}",
4204 result
4205 );
4206 }
4207
4208 #[test]
4215 fn py_sup_03_barrel_suppression_l1_unmatched_gets_barrel() {
4216 use tempfile::TempDir;
4217
4218 let dir = TempDir::new().unwrap();
4222 let pkg = dir.path().join("pkg");
4223 std::fs::create_dir_all(&pkg).unwrap();
4224 let tests_dir = dir.path().join("tests");
4225 std::fs::create_dir_all(&tests_dir).unwrap();
4226
4227 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4228 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4229 std::fs::write(
4230 pkg.join("__init__.py"),
4231 "from ._client import Client\nfrom ._utils import format_url\n",
4232 )
4233 .unwrap();
4234
4235 let test_content = "import pkg\n\ndef test_exported_members():\n pass\n";
4237 std::fs::write(tests_dir.join("test_exported_members.py"), test_content).unwrap();
4238
4239 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4240 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4241 let test_path = tests_dir
4242 .join("test_exported_members.py")
4243 .to_string_lossy()
4244 .into_owned();
4245
4246 let extractor = PythonExtractor::new();
4247 let production_files = vec![client_path.clone(), utils_path.clone()];
4248 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4249 .into_iter()
4250 .collect();
4251
4252 let result =
4254 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4255
4256 let client_mapped = result
4259 .iter()
4260 .find(|m| m.production_file == client_path)
4261 .map(|m| m.test_files.contains(&test_path))
4262 .unwrap_or(false);
4263 let utils_mapped = result
4264 .iter()
4265 .find(|m| m.production_file == utils_path)
4266 .map(|m| m.test_files.contains(&test_path))
4267 .unwrap_or(false);
4268
4269 assert!(
4270 client_mapped && utils_mapped,
4271 "L1-unmatched test should fan-out via barrel to both _client.py and _utils.py. client_mapped={}, utils_mapped={}, mappings={:?}",
4272 client_mapped,
4273 utils_mapped,
4274 result
4275 );
4276 }
4277
4278 #[test]
4290 fn py_sup_04_e2e_httpx_like_precision_improvement() {
4291 use tempfile::TempDir;
4292 use HashSet;
4293
4294 let dir = TempDir::new().unwrap();
4302 let pkg = dir.path().join("pkg");
4303 std::fs::create_dir_all(&pkg).unwrap();
4304 let tests_dir = dir.path().join("tests");
4305 std::fs::create_dir_all(&tests_dir).unwrap();
4306
4307 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4308 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4309 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4310 std::fs::write(
4311 pkg.join("__init__.py"),
4312 "from ._client import Client\nfrom ._decoders import decode\nfrom ._utils import format_url\n",
4313 )
4314 .unwrap();
4315
4316 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4317 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4318 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4319 let production_files = vec![
4320 client_path.clone(),
4321 decoders_path.clone(),
4322 utils_path.clone(),
4323 ];
4324
4325 let test_client_content = "import pkg\n\ndef test_client():\n pass\n";
4329 let test_decoders_content = "import pkg\n\ndef test_decode():\n pass\n";
4330 let test_utils_content = "import pkg\n\ndef test_format_url():\n pass\n";
4331 let test_exported_content = "import pkg\n\ndef test_exported_members():\n pass\n";
4332
4333 let test_client_path = tests_dir
4334 .join("test_client.py")
4335 .to_string_lossy()
4336 .into_owned();
4337 let test_decoders_path = tests_dir
4338 .join("test_decoders.py")
4339 .to_string_lossy()
4340 .into_owned();
4341 let test_utils_path = tests_dir
4342 .join("test_utils.py")
4343 .to_string_lossy()
4344 .into_owned();
4345 let test_exported_path = tests_dir
4346 .join("test_exported_members.py")
4347 .to_string_lossy()
4348 .into_owned();
4349
4350 std::fs::write(&test_client_path, test_client_content).unwrap();
4351 std::fs::write(&test_decoders_path, test_decoders_content).unwrap();
4352 std::fs::write(&test_utils_path, test_utils_content).unwrap();
4353 std::fs::write(&test_exported_path, test_exported_content).unwrap();
4354
4355 let test_sources: HashMap<String, String> = [
4356 (test_client_path.clone(), test_client_content.to_string()),
4357 (
4358 test_decoders_path.clone(),
4359 test_decoders_content.to_string(),
4360 ),
4361 (test_utils_path.clone(), test_utils_content.to_string()),
4362 (
4363 test_exported_path.clone(),
4364 test_exported_content.to_string(),
4365 ),
4366 ]
4367 .into_iter()
4368 .collect();
4369
4370 let extractor = PythonExtractor::new();
4371
4372 let result =
4374 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4375
4376 let ground_truth_set: HashSet<(String, String)> = [
4382 (test_client_path.clone(), client_path.clone()),
4383 (test_decoders_path.clone(), decoders_path.clone()),
4384 (test_utils_path.clone(), utils_path.clone()),
4385 (test_exported_path.clone(), client_path.clone()),
4386 (test_exported_path.clone(), decoders_path.clone()),
4387 (test_exported_path.clone(), utils_path.clone()),
4388 ]
4389 .into_iter()
4390 .collect();
4391
4392 let actual_pairs: HashSet<(String, String)> = result
4393 .iter()
4394 .flat_map(|m| {
4395 m.test_files
4396 .iter()
4397 .map(|t| (t.clone(), m.production_file.clone()))
4398 .collect::<Vec<_>>()
4399 })
4400 .collect();
4401
4402 let tp = actual_pairs.intersection(&ground_truth_set).count();
4403 let fp = actual_pairs.difference(&ground_truth_set).count();
4404
4405 let precision = if tp + fp == 0 {
4407 0.0
4408 } else {
4409 tp as f64 / (tp + fp) as f64
4410 };
4411
4412 assert!(
4417 precision >= 0.80,
4418 "Precision {:.1}% < 80% target. TP={}, FP={}, actual_pairs={:?}",
4419 precision * 100.0,
4420 tp,
4421 fp,
4422 actual_pairs
4423 );
4424 }
4425
4426 #[test]
4431 fn py_af_01_assert_via_assigned_var() {
4432 let source = r#"
4434from pkg.client import Client
4435
4436def test_something():
4437 client = Client()
4438 assert client.ok
4439"#;
4440 let result = extract_assertion_referenced_imports(source);
4442
4443 assert!(
4445 result.contains("Client"),
4446 "Client should be in asserted_imports; got {:?}",
4447 result
4448 );
4449 }
4450
4451 #[test]
4456 fn py_af_02_setup_only_import_excluded() {
4457 let source = r#"
4459from pkg.client import Client
4460from pkg.transport import MockTransport
4461
4462def test_something():
4463 transport = MockTransport()
4464 client = Client(transport=transport)
4465 assert client.ok
4466"#;
4467 let result = extract_assertion_referenced_imports(source);
4469
4470 assert!(
4472 !result.contains("MockTransport"),
4473 "MockTransport should NOT be in asserted_imports (setup-only); got {:?}",
4474 result
4475 );
4476 assert!(
4478 result.contains("Client"),
4479 "Client should be in asserted_imports; got {:?}",
4480 result
4481 );
4482 }
4483
4484 #[test]
4489 fn py_af_03_direct_call_in_assertion() {
4490 let source = r#"
4492from pkg.models import A, B
4493
4494def test_equality():
4495 assert A() == B()
4496"#;
4497 let result = extract_assertion_referenced_imports(source);
4499
4500 assert!(
4502 result.contains("A"),
4503 "A should be in asserted_imports (used directly in assert); got {:?}",
4504 result
4505 );
4506 assert!(
4507 result.contains("B"),
4508 "B should be in asserted_imports (used directly in assert); got {:?}",
4509 result
4510 );
4511 }
4512
4513 #[test]
4518 fn py_af_04_pytest_raises_captures_exception_class() {
4519 let source = r#"
4521import pytest
4522from pkg.exceptions import HTTPError
4523
4524def test_raises():
4525 with pytest.raises(HTTPError):
4526 raise HTTPError("fail")
4527"#;
4528 let result = extract_assertion_referenced_imports(source);
4530
4531 assert!(
4533 result.contains("HTTPError"),
4534 "HTTPError should be in asserted_imports (pytest.raises arg); got {:?}",
4535 result
4536 );
4537 }
4538
4539 #[test]
4545 fn py_af_05_chain_tracking_two_hops() {
4546 let source = r#"
4548from pkg.client import Client
4549
4550def test_response():
4551 client = Client()
4552 response = client.get("http://example.com/")
4553 assert response.ok
4554"#;
4555 let result = extract_assertion_referenced_imports(source);
4557
4558 assert!(
4560 result.contains("Client"),
4561 "Client should be in asserted_imports via 2-hop chain; got {:?}",
4562 result
4563 );
4564 }
4565
4566 #[test]
4572 fn py_af_06a_no_assertions_returns_empty() {
4573 let source = r#"
4575from pkg.client import Client
4576from pkg.transport import MockTransport
4577
4578def test_setup_no_assert():
4579 client = Client()
4580 transport = MockTransport()
4581 # No assert statement at all
4582"#;
4583 let result = extract_assertion_referenced_imports(source);
4585
4586 assert!(
4589 result.is_empty(),
4590 "expected empty asserted_imports when no assertions present; got {:?}",
4591 result
4592 );
4593 }
4594
4595 #[test]
4601 fn py_af_06b_assertion_exists_but_no_import_intersection() {
4602 let source = r#"
4604from pkg.client import Client
4605
4606def test_local_only():
4607 local_value = 42
4608 # Assertion references only a local literal, not any imported symbol
4609 assert local_value == 42
4610"#;
4611 let result = extract_assertion_referenced_imports(source);
4613
4614 assert!(
4617 !result.contains("Client"),
4618 "Client should NOT be in asserted_imports (not referenced in assertion); got {:?}",
4619 result
4620 );
4621 }
4624
4625 #[test]
4631 fn py_af_07_unittest_self_assert() {
4632 let source = r#"
4634import unittest
4635from pkg.models import MyModel
4636
4637class TestMyModel(unittest.TestCase):
4638 def test_value(self):
4639 result = MyModel()
4640 self.assertEqual(result.value, 42)
4641"#;
4642 let result = extract_assertion_referenced_imports(source);
4644
4645 assert!(
4647 result.contains("MyModel"),
4648 "MyModel should be in asserted_imports via self.assertEqual; got {:?}",
4649 result
4650 );
4651 }
4652
4653 #[test]
4662 fn py_af_08_e2e_primary_kept_incidental_filtered() {
4663 use std::path::PathBuf;
4664 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4665 .parent()
4666 .unwrap()
4667 .parent()
4668 .unwrap()
4669 .join("tests/fixtures/python/observe/af_pkg");
4670
4671 let test_file = fixture_root
4672 .join("tests/test_client.py")
4673 .to_string_lossy()
4674 .into_owned();
4675 let client_prod = fixture_root
4676 .join("pkg/client.py")
4677 .to_string_lossy()
4678 .into_owned();
4679 let transport_prod = fixture_root
4680 .join("pkg/transport.py")
4681 .to_string_lossy()
4682 .into_owned();
4683
4684 let production_files = vec![client_prod.clone(), transport_prod.clone()];
4685 let test_source =
4686 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4687 let mut test_sources = HashMap::new();
4688 test_sources.insert(test_file.clone(), test_source);
4689
4690 let extractor = PythonExtractor::new();
4692 let result =
4693 extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4694
4695 let client_mapping = result.iter().find(|m| m.production_file == client_prod);
4697 assert!(
4698 client_mapping.is_some(),
4699 "client.py should be in mappings; got {:?}",
4700 result
4701 .iter()
4702 .map(|m| &m.production_file)
4703 .collect::<Vec<_>>()
4704 );
4705 assert!(
4706 client_mapping.unwrap().test_files.contains(&test_file),
4707 "test_client.py should map to client.py"
4708 );
4709
4710 let transport_mapping = result.iter().find(|m| m.production_file == transport_prod);
4712 let transport_maps_test = transport_mapping
4713 .map(|m| m.test_files.contains(&test_file))
4714 .unwrap_or(false);
4715 assert!(
4716 !transport_maps_test,
4717 "test_client.py should NOT map to transport.py (assertion filter); got {:?}",
4718 result
4719 .iter()
4720 .map(|m| (&m.production_file, &m.test_files))
4721 .collect::<Vec<_>>()
4722 );
4723 }
4724
4725 #[test]
4735 fn py_af_09_e2e_all_incidental_fallback_no_fn() {
4736 use std::path::PathBuf;
4737 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4738 .parent()
4739 .unwrap()
4740 .parent()
4741 .unwrap()
4742 .join("tests/fixtures/python/observe/af_e2e_fallback");
4743
4744 let test_file = fixture_root
4745 .join("tests/test_helpers.py")
4746 .to_string_lossy()
4747 .into_owned();
4748 let helpers_prod = fixture_root
4749 .join("pkg/helpers.py")
4750 .to_string_lossy()
4751 .into_owned();
4752
4753 let production_files = vec![helpers_prod.clone()];
4754 let test_source =
4755 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4756 let mut test_sources = HashMap::new();
4757 test_sources.insert(test_file.clone(), test_source);
4758
4759 let extractor = PythonExtractor::new();
4761 let result =
4762 extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4763
4764 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
4766 assert!(
4767 helpers_mapping.is_some(),
4768 "helpers.py should be in mappings (fallback); got {:?}",
4769 result
4770 .iter()
4771 .map(|m| &m.production_file)
4772 .collect::<Vec<_>>()
4773 );
4774 assert!(
4775 helpers_mapping.unwrap().test_files.contains(&test_file),
4776 "test_helpers.py should map to helpers.py (fallback, no FN)"
4777 );
4778 }
4779
4780 #[test]
4794 fn py_af_10_e2e_http_client_primary_mapped() {
4795 use std::path::PathBuf;
4796 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4797 .parent()
4798 .unwrap()
4799 .parent()
4800 .unwrap()
4801 .join("tests/fixtures/python/observe/af_e2e_http");
4802
4803 let test_file = fixture_root
4804 .join("tests/test_http_client.py")
4805 .to_string_lossy()
4806 .into_owned();
4807 let http_client_prod = fixture_root
4808 .join("pkg/http_client.py")
4809 .to_string_lossy()
4810 .into_owned();
4811 let exceptions_prod = fixture_root
4812 .join("pkg/exceptions.py")
4813 .to_string_lossy()
4814 .into_owned();
4815
4816 let production_files = vec![http_client_prod.clone(), exceptions_prod.clone()];
4817 let test_source =
4818 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4819 let mut test_sources = HashMap::new();
4820 test_sources.insert(test_file.clone(), test_source);
4821
4822 let extractor = PythonExtractor::new();
4824 let result =
4825 extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4826
4827 let http_client_mapping = result
4829 .iter()
4830 .find(|m| m.production_file == http_client_prod);
4831 assert!(
4832 http_client_mapping.is_some(),
4833 "http_client.py should be in mappings; got {:?}",
4834 result
4835 .iter()
4836 .map(|m| &m.production_file)
4837 .collect::<Vec<_>>()
4838 );
4839 assert!(
4840 http_client_mapping.unwrap().test_files.contains(&test_file),
4841 "test_http_client.py should map to http_client.py (primary SUT)"
4842 );
4843 }
4844
4845 #[test]
4849 fn py_e2e_helper_excluded_from_mappings() {
4850 let tmp = tempfile::tempdir().unwrap();
4853 let root = tmp.path();
4854
4855 let files: &[(&str, &str)] = &[
4857 ("pkg/__init__.py", ""),
4858 ("pkg/client.py", "class Client:\n def connect(self):\n return True\n"),
4859 ("tests/__init__.py", ""),
4860 ("tests/helpers.py", "def mock_client():\n return \"mock\"\n"),
4861 (
4862 "tests/test_client.py",
4863 "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n client = Client()\n assert client.connect()\n\ndef test_with_mock():\n mc = mock_client()\n assert mc == \"mock\"\n",
4864 ),
4865 ];
4866 for (rel, content) in files {
4867 let path = root.join(rel);
4868 if let Some(parent) = path.parent() {
4869 std::fs::create_dir_all(parent).unwrap();
4870 }
4871 std::fs::write(&path, content).unwrap();
4872 }
4873
4874 let extractor = PythonExtractor::new();
4875
4876 let client_abs = root.join("pkg/client.py").to_string_lossy().into_owned();
4879 let helpers_abs = root.join("tests/helpers.py").to_string_lossy().into_owned();
4880 let production_files = vec![client_abs.clone(), helpers_abs.clone()];
4881
4882 let test_abs = root
4883 .join("tests/test_client.py")
4884 .to_string_lossy()
4885 .into_owned();
4886 let test_content = "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n client = Client()\n assert client.connect()\n\ndef test_with_mock():\n mc = mock_client()\n assert mc == \"mock\"\n";
4887 let test_sources: HashMap<String, String> = [(test_abs.clone(), test_content.to_string())]
4888 .into_iter()
4889 .collect();
4890
4891 let mappings =
4893 extractor.map_test_files_with_imports(&production_files, &test_sources, root);
4894
4895 for m in &mappings {
4897 assert!(
4898 !m.production_file.contains("helpers.py"),
4899 "helpers.py should be excluded as test helper, but found in mapping: {:?}",
4900 m
4901 );
4902 }
4903
4904 let client_mapping = mappings
4906 .iter()
4907 .find(|m| m.production_file.contains("client.py"));
4908 assert!(
4909 client_mapping.is_some(),
4910 "pkg/client.py should be mapped; got {:?}",
4911 mappings
4912 .iter()
4913 .map(|m| &m.production_file)
4914 .collect::<Vec<_>>()
4915 );
4916 let client_mapping = client_mapping.unwrap();
4917 assert!(
4918 client_mapping
4919 .test_files
4920 .iter()
4921 .any(|t| t.contains("test_client.py")),
4922 "pkg/client.py should map to test_client.py; got {:?}",
4923 client_mapping.test_files
4924 );
4925 }
4926
4927 #[test]
4933 fn py_fp_01_mock_transport_fixture_not_mapped() {
4934 use tempfile::TempDir;
4935
4936 let dir = TempDir::new().unwrap();
4937 let root = dir.path();
4938 let pkg = root.join("pkg");
4939 let transports = pkg.join("_transports");
4940 let tests_dir = root.join("tests");
4941 std::fs::create_dir_all(&transports).unwrap();
4942 std::fs::create_dir_all(&tests_dir).unwrap();
4943
4944 std::fs::write(
4945 transports.join("mock.py"),
4946 "class MockTransport:\n pass\n",
4947 )
4948 .unwrap();
4949 std::fs::write(
4950 transports.join("__init__.py"),
4951 "from .mock import MockTransport\n",
4952 )
4953 .unwrap();
4954 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4955 std::fs::write(
4956 pkg.join("__init__.py"),
4957 "from ._transports import *\nfrom ._client import Client\n",
4958 )
4959 .unwrap();
4960
4961 let test_content = "import pkg\n\ndef test_hooks():\n client = pkg.Client(transport=pkg.MockTransport())\n assert client is not None\n";
4962 std::fs::write(tests_dir.join("test_hooks.py"), test_content).unwrap();
4963
4964 let mock_path = transports.join("mock.py").to_string_lossy().into_owned();
4965 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4966 let test_path = tests_dir
4967 .join("test_hooks.py")
4968 .to_string_lossy()
4969 .into_owned();
4970
4971 let extractor = PythonExtractor::new();
4972 let production_files = vec![mock_path.clone(), client_path.clone()];
4973 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4974 .into_iter()
4975 .collect();
4976
4977 let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
4978
4979 let mock_mapping = result.iter().find(|m| m.production_file == mock_path);
4980 assert!(
4981 mock_mapping.is_none() || mock_mapping.unwrap().test_files.is_empty(),
4982 "mock.py should NOT be mapped (fixture); mappings={:?}",
4983 result
4984 );
4985 }
4986
4987 #[test]
4993 fn py_fp_02_version_py_incidental_not_mapped() {
4994 use tempfile::TempDir;
4995
4996 let dir = TempDir::new().unwrap();
4997 let root = dir.path();
4998 let pkg = root.join("pkg");
4999 let tests_dir = root.join("tests");
5000 std::fs::create_dir_all(&pkg).unwrap();
5001 std::fs::create_dir_all(&tests_dir).unwrap();
5002
5003 std::fs::write(pkg.join("__version__.py"), "__version__ = \"1.0.0\"\n").unwrap();
5004 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5005 std::fs::write(
5006 pkg.join("__init__.py"),
5007 "from .__version__ import __version__\nfrom ._client import Client\n",
5008 )
5009 .unwrap();
5010
5011 let test_content = "import pkg\n\ndef test_headers():\n expected = f\"python-pkg/{pkg.__version__}\"\n assert expected == \"python-pkg/1.0.0\"\n";
5012 std::fs::write(tests_dir.join("test_headers.py"), test_content).unwrap();
5013
5014 let version_path = pkg.join("__version__.py").to_string_lossy().into_owned();
5015 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5016 let test_path = tests_dir
5017 .join("test_headers.py")
5018 .to_string_lossy()
5019 .into_owned();
5020
5021 let extractor = PythonExtractor::new();
5022 let production_files = vec![version_path.clone(), client_path.clone()];
5023 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5024 .into_iter()
5025 .collect();
5026
5027 let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
5028
5029 let version_mapping = result.iter().find(|m| m.production_file == version_path);
5030 assert!(
5031 version_mapping.is_none() || version_mapping.unwrap().test_files.is_empty(),
5032 "__version__.py should NOT be mapped (metadata); mappings={:?}",
5033 result
5034 );
5035 }
5036
5037 #[test]
5043 fn py_fp_03_types_py_annotation_not_mapped() {
5044 use tempfile::TempDir;
5045
5046 let dir = TempDir::new().unwrap();
5047 let root = dir.path();
5048 let pkg = root.join("pkg");
5049 let tests_dir = root.join("tests");
5050 std::fs::create_dir_all(&pkg).unwrap();
5051 std::fs::create_dir_all(&tests_dir).unwrap();
5052
5053 std::fs::write(
5054 pkg.join("_types.py"),
5055 "from typing import Union\nQueryParamTypes = Union[str, dict]\n",
5056 )
5057 .unwrap();
5058 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5059 std::fs::write(
5060 pkg.join("__init__.py"),
5061 "from ._types import *\nfrom ._client import Client\n",
5062 )
5063 .unwrap();
5064
5065 let test_content = "import pkg\n\ndef test_client():\n client = pkg.Client()\n assert client is not None\n";
5066 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5067
5068 let types_path = pkg.join("_types.py").to_string_lossy().into_owned();
5069 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5070 let test_path = tests_dir
5071 .join("test_client.py")
5072 .to_string_lossy()
5073 .into_owned();
5074
5075 let extractor = PythonExtractor::new();
5076 let production_files = vec![types_path.clone(), client_path.clone()];
5077 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5078 .into_iter()
5079 .collect();
5080
5081 let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
5082
5083 let types_mapping = result.iter().find(|m| m.production_file == types_path);
5084 assert!(
5085 types_mapping.is_none() || types_mapping.unwrap().test_files.is_empty(),
5086 "_types.py should NOT be mapped (type definitions); mappings={:?}",
5087 result
5088 );
5089 }
5090}