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 if stem == "tests" {
64 let sep_pos = path.rfind('/')?;
65 let before_sep = &path[..sep_pos];
66 let parent_start = before_sep.rfind('/').map(|i| i + 1).unwrap_or(0);
67 let parent_name = &path[parent_start..sep_pos];
68 if parent_name.is_empty() {
69 return None;
70 }
71 return Some(parent_name);
72 }
73 None
74}
75
76pub fn production_stem(path: &str) -> Option<&str> {
82 let file_name = Path::new(path).file_name()?.to_str()?;
83 let stem = file_name.strip_suffix(".py")?;
84 if stem == "__init__" {
86 return None;
87 }
88 if stem == "tests" {
90 return None;
91 }
92 if stem.starts_with("test_") || stem.ends_with("_test") {
94 return None;
95 }
96 let stem = stem.strip_prefix('_').unwrap_or(stem);
97 let stem = stem.strip_suffix("__").unwrap_or(stem);
98 Some(stem)
99}
100
101pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
103 let in_test_dir = file_path
108 .split('/')
109 .any(|seg| seg == "tests" || seg == "test");
110
111 if in_test_dir {
112 return true;
113 }
114
115 let stem_only = Path::new(file_path)
119 .file_stem()
120 .and_then(|f| f.to_str())
121 .unwrap_or("");
122
123 if stem_only == "__version__" {
125 return true;
126 }
127
128 {
130 let normalized = stem_only.trim_matches('_');
131 if normalized == "types" || normalized.ends_with("_types") {
132 return true;
133 }
134 }
135
136 if stem_only == "mock" || stem_only.starts_with("mock_") {
138 return true;
139 }
140
141 if is_known_production {
142 return false;
143 }
144
145 let file_name = Path::new(file_path)
146 .file_name()
147 .and_then(|f| f.to_str())
148 .unwrap_or("");
149
150 if matches!(
152 file_name,
153 "conftest.py" | "constants.py" | "setup.py" | "__init__.py"
154 ) {
155 return true;
156 }
157
158 let parent_is_pycache = Path::new(file_path)
160 .parent()
161 .and_then(|p| p.file_name())
162 .and_then(|f| f.to_str())
163 .map(|s| s == "__pycache__")
164 .unwrap_or(false);
165
166 if parent_is_pycache {
167 return true;
168 }
169
170 false
171}
172
173fn extract_bare_import_attributes(
182 source_bytes: &[u8],
183 tree: &tree_sitter::Tree,
184 module_name: &str,
185) -> Vec<String> {
186 let query = cached_query(
187 &BARE_IMPORT_ATTRIBUTE_QUERY_CACHE,
188 BARE_IMPORT_ATTRIBUTE_QUERY,
189 );
190 let module_name_idx = query.capture_index_for_name("module_name").unwrap();
191 let attribute_name_idx = query.capture_index_for_name("attribute_name").unwrap();
192
193 let mut cursor = QueryCursor::new();
194 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
195
196 let mut attrs: Vec<String> = Vec::new();
197 while let Some(m) = matches.next() {
198 let mut mod_text = "";
199 let mut attr_text = "";
200 for cap in m.captures {
201 if cap.index == module_name_idx {
202 mod_text = cap.node.utf8_text(source_bytes).unwrap_or("");
203 } else if cap.index == attribute_name_idx {
204 attr_text = cap.node.utf8_text(source_bytes).unwrap_or("");
205 }
206 }
207 if mod_text == module_name && !attr_text.is_empty() {
208 attrs.push(attr_text.to_string());
209 }
210 }
211 attrs.sort();
212 attrs.dedup();
213 attrs
214}
215
216impl ObserveExtractor for PythonExtractor {
221 fn extract_production_functions(
222 &self,
223 source: &str,
224 file_path: &str,
225 ) -> Vec<ProductionFunction> {
226 let mut parser = Self::parser();
227 let tree = match parser.parse(source, None) {
228 Some(t) => t,
229 None => return Vec::new(),
230 };
231 let source_bytes = source.as_bytes();
232 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
233
234 let name_idx = query.capture_index_for_name("name");
236 let class_name_idx = query.capture_index_for_name("class_name");
237 let method_name_idx = query.capture_index_for_name("method_name");
238 let decorated_name_idx = query.capture_index_for_name("decorated_name");
239 let decorated_class_name_idx = query.capture_index_for_name("decorated_class_name");
240 let decorated_method_name_idx = query.capture_index_for_name("decorated_method_name");
241
242 let fn_name_indices: [Option<u32>; 4] = [
244 name_idx,
245 method_name_idx,
246 decorated_name_idx,
247 decorated_method_name_idx,
248 ];
249 let class_name_indices: [Option<u32>; 2] = [class_name_idx, decorated_class_name_idx];
251
252 let mut cursor = QueryCursor::new();
253 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
254 let mut result = Vec::new();
255
256 while let Some(m) = matches.next() {
257 let mut fn_name: Option<String> = None;
259 let mut class_name: Option<String> = None;
260 let mut line: usize = 1;
261
262 for cap in m.captures {
263 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
264 let node_line = cap.node.start_position().row + 1;
265
266 if fn_name_indices.contains(&Some(cap.index)) {
267 fn_name = Some(text);
268 line = node_line;
269 } else if class_name_indices.contains(&Some(cap.index)) {
270 class_name = Some(text);
271 }
272 }
273
274 if let Some(name) = fn_name {
275 result.push(ProductionFunction {
276 name,
277 file: file_path.to_string(),
278 line,
279 class_name,
280 is_exported: true,
281 });
282 }
283 }
284
285 let mut seen = HashSet::new();
287 result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
288
289 result
290 }
291
292 fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
293 let mut parser = Self::parser();
294 let tree = match parser.parse(source, None) {
295 Some(t) => t,
296 None => return Vec::new(),
297 };
298 let source_bytes = source.as_bytes();
299 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
300
301 let module_name_idx = query.capture_index_for_name("module_name");
302 let symbol_name_idx = query.capture_index_for_name("symbol_name");
303
304 let mut cursor = QueryCursor::new();
305 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
306
307 let mut raw: Vec<(String, String, usize)> = Vec::new();
309
310 while let Some(m) = matches.next() {
311 let mut module_text: Option<String> = None;
312 let mut symbol_text: Option<String> = None;
313 let mut symbol_line: usize = 1;
314
315 for cap in m.captures {
316 if module_name_idx == Some(cap.index) {
317 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
318 } else if symbol_name_idx == Some(cap.index) {
319 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
320 symbol_line = cap.node.start_position().row + 1;
321 }
322 }
323
324 let (module_text, symbol_text) = match (module_text, symbol_text) {
325 (Some(m), Some(s)) => (m, s),
326 _ => continue,
327 };
328
329 let specifier_base = python_module_to_relative_specifier(&module_text);
334
335 if specifier_base.starts_with("./") || specifier_base.starts_with("../") {
337 let specifier = if specifier_base == "./"
340 && !module_text.contains('/')
341 && module_text.chars().all(|c| c == '.')
342 {
343 format!("./{symbol_text}")
344 } else {
345 specifier_base
346 };
347 raw.push((specifier, symbol_text, symbol_line));
348 }
349 }
350
351 let mut specifier_symbols: HashMap<String, Vec<(String, usize)>> = HashMap::new();
353 for (spec, sym, line) in &raw {
354 specifier_symbols
355 .entry(spec.clone())
356 .or_default()
357 .push((sym.clone(), *line));
358 }
359
360 let mut result = Vec::new();
362 for (specifier, sym_lines) in &specifier_symbols {
363 let all_symbols: Vec<String> = sym_lines.iter().map(|(s, _)| s.clone()).collect();
364 for (sym, line) in sym_lines {
365 result.push(ImportMapping {
366 symbol_name: sym.clone(),
367 module_specifier: specifier.clone(),
368 file: file_path.to_string(),
369 line: *line,
370 symbols: all_symbols.clone(),
371 });
372 }
373 }
374
375 result
376 }
377
378 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
379 let mut parser = Self::parser();
380 let tree = match parser.parse(source, None) {
381 Some(t) => t,
382 None => return Vec::new(),
383 };
384 let source_bytes = source.as_bytes();
385 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
386
387 let module_name_idx = query.capture_index_for_name("module_name");
388 let symbol_name_idx = query.capture_index_for_name("symbol_name");
389 let import_name_idx = query.capture_index_for_name("import_name");
390
391 let mut cursor = QueryCursor::new();
392 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
393
394 let mut specifier_symbols: HashMap<String, Vec<String>> = HashMap::new();
395
396 while let Some(m) = matches.next() {
397 let mut module_text: Option<String> = None;
398 let mut symbol_text: Option<String> = None;
399 let mut import_name_parts: Vec<String> = Vec::new();
400
401 for cap in m.captures {
402 if module_name_idx == Some(cap.index) {
403 module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
404 } else if symbol_name_idx == Some(cap.index) {
405 symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
406 } else if import_name_idx == Some(cap.index) {
407 let dotted_text = cap
410 .node
411 .parent()
412 .and_then(|p| p.utf8_text(source_bytes).ok())
413 .unwrap_or_else(|| cap.node.utf8_text(source_bytes).unwrap_or(""))
414 .to_string();
415 import_name_parts.push(dotted_text);
416 }
417 }
418
419 if !import_name_parts.is_empty() {
420 import_name_parts.dedup();
423 let specifier = python_module_to_absolute_specifier(&import_name_parts[0]);
424 if !specifier.starts_with("./")
425 && !specifier.starts_with("../")
426 && !specifier.is_empty()
427 {
428 let attrs =
429 extract_bare_import_attributes(source_bytes, &tree, &import_name_parts[0]);
430 specifier_symbols.entry(specifier).or_insert_with(|| attrs);
431 }
432 continue;
433 }
434
435 let (module_text, symbol_text) = match (module_text, symbol_text) {
436 (Some(m), Some(s)) => (m, s),
437 _ => continue,
438 };
439
440 let specifier = python_module_to_absolute_specifier(&module_text);
442
443 if specifier.starts_with("./") || specifier.starts_with("../") || specifier.is_empty() {
446 continue;
447 }
448
449 specifier_symbols
450 .entry(specifier)
451 .or_default()
452 .push(symbol_text);
453 }
454
455 specifier_symbols.into_iter().collect()
456 }
457
458 fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
459 let mut parser = Self::parser();
460 let tree = match parser.parse(source, None) {
461 Some(t) => t,
462 None => return Vec::new(),
463 };
464 let source_bytes = source.as_bytes();
465 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
466
467 let from_specifier_idx = query
468 .capture_index_for_name("from_specifier")
469 .expect("@from_specifier capture not found in re_export.scm");
470 let symbol_name_idx = query.capture_index_for_name("symbol_name");
471 let wildcard_idx = query.capture_index_for_name("wildcard");
472
473 let mut cursor = QueryCursor::new();
474 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
475
476 struct ReExportEntry {
478 symbols: Vec<String>,
479 wildcard: bool,
480 }
481 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
482
483 while let Some(m) = matches.next() {
484 let mut from_spec: Option<String> = None;
485 let mut sym: Option<String> = None;
486 let mut is_wildcard = false;
487
488 for cap in m.captures {
489 if cap.index == from_specifier_idx {
490 let raw = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
491 from_spec = Some(python_module_to_relative_specifier(&raw));
492 } else if wildcard_idx == Some(cap.index) {
493 is_wildcard = true;
494 } else if symbol_name_idx == Some(cap.index) {
495 sym = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
496 }
497 }
498
499 if let Some(spec) = from_spec {
500 if spec.starts_with("./") || spec.starts_with("../") {
502 let entry = grouped.entry(spec).or_insert(ReExportEntry {
503 symbols: Vec::new(),
504 wildcard: false,
505 });
506 if is_wildcard {
507 entry.wildcard = true;
508 }
509 if let Some(symbol) = sym {
510 if !entry.symbols.contains(&symbol) {
511 entry.symbols.push(symbol);
512 }
513 }
514 }
515 }
516 }
517
518 grouped
519 .into_iter()
520 .map(|(from_specifier, entry)| BarrelReExport {
521 symbols: entry.symbols,
522 from_specifier,
523 wildcard: entry.wildcard,
524 namespace_wildcard: false,
525 })
526 .collect()
527 }
528
529 fn source_extensions(&self) -> &[&str] {
530 &["py"]
531 }
532
533 fn index_file_names(&self) -> &[&str] {
534 &["__init__.py"]
535 }
536
537 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
538 production_stem(path)
539 }
540
541 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
542 test_stem(path)
543 }
544
545 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
546 is_non_sut_helper(file_path, is_known_production)
547 }
548
549 fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
550 let source = match std::fs::read_to_string(file_path) {
551 Ok(s) => s,
552 Err(_) => return true, };
554
555 let mut parser = Self::parser();
556 let tree = match parser.parse(&source, None) {
557 Some(t) => t,
558 None => return true,
559 };
560 let source_bytes = source.as_bytes();
561 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
562
563 let symbol_idx = query.capture_index_for_name("symbol");
564 let all_decl_idx = query.capture_index_for_name("all_decl");
565 let var_name_idx = query.capture_index_for_name("var_name");
566
567 let mut cursor = QueryCursor::new();
568 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
569
570 let mut all_symbols: Vec<String> = Vec::new();
571 let mut found_all = false;
572
573 while let Some(m) = matches.next() {
574 for cap in m.captures {
575 if var_name_idx == Some(cap.index) || all_decl_idx == Some(cap.index) {
577 found_all = true;
578 } else if symbol_idx == Some(cap.index) {
579 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
580 let stripped = raw.trim_matches(|c| c == '\'' || c == '"');
581 all_symbols.push(stripped.to_string());
582 }
583 }
584 }
585
586 if !found_all {
587 return true;
589 }
590
591 symbols.iter().any(|s| all_symbols.contains(s))
593 }
594}
595
596fn python_module_to_relative_specifier(module: &str) -> String {
612 let dot_count = module.chars().take_while(|&c| c == '.').count();
614 if dot_count == 0 {
615 return module.to_string();
617 }
618
619 let rest = &module[dot_count..];
620
621 if dot_count == 1 {
622 if rest.is_empty() {
623 "./".to_string()
626 } else {
627 format!("./{rest}")
628 }
629 } else {
630 let prefix = "../".repeat(dot_count - 1);
632 if rest.is_empty() {
633 prefix
635 } else {
636 format!("{prefix}{rest}")
637 }
638 }
639}
640
641fn python_module_to_absolute_specifier(module: &str) -> String {
645 if module.starts_with('.') {
646 return python_module_to_relative_specifier(module);
648 }
649 module.replace('.', "/")
650}
651
652pub fn extract_assertion_referenced_imports(source: &str) -> HashSet<String> {
673 let mut parser = PythonExtractor::parser();
674 let tree = match parser.parse(source, None) {
675 Some(t) => t,
676 None => return HashSet::new(),
677 };
678 let source_bytes = source.as_bytes();
679
680 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
682 let assertion_cap_idx = match assertion_query.capture_index_for_name("assertion") {
683 Some(idx) => idx,
684 None => return HashSet::new(),
685 };
686
687 let mut assertion_ranges: Vec<(usize, usize)> = Vec::new();
688 {
689 let mut cursor = QueryCursor::new();
690 let mut matches = cursor.matches(assertion_query, tree.root_node(), source_bytes);
691 while let Some(m) = matches.next() {
692 for cap in m.captures {
693 if cap.index == assertion_cap_idx {
694 let r = cap.node.byte_range();
695 assertion_ranges.push((r.start, r.end));
696 }
697 }
698 }
699 }
700
701 if assertion_ranges.is_empty() {
702 return HashSet::new();
703 }
704
705 let mut assertion_identifiers: HashSet<String> = HashSet::new();
707 {
708 let root = tree.root_node();
709 let mut stack = vec![root];
710 while let Some(node) = stack.pop() {
711 let nr = node.byte_range();
712 let overlaps = assertion_ranges
714 .iter()
715 .any(|&(s, e)| nr.start < e && nr.end > s);
716 if !overlaps {
717 continue;
718 }
719 if node.kind() == "identifier" {
720 if assertion_ranges
722 .iter()
723 .any(|&(s, e)| nr.start >= s && nr.end <= e)
724 {
725 if let Ok(text) = node.utf8_text(source_bytes) {
726 if !text.is_empty() {
727 assertion_identifiers.insert(text.to_string());
728 }
729 }
730 }
731 }
732 for i in 0..node.child_count() {
733 if let Some(child) = node.child(i) {
734 stack.push(child);
735 }
736 }
737 }
738 }
739
740 let assign_query = cached_query(&ASSIGNMENT_MAPPING_QUERY_CACHE, ASSIGNMENT_MAPPING_QUERY);
743 let var_idx = assign_query.capture_index_for_name("var");
744 let class_idx = assign_query.capture_index_for_name("class");
745 let source_idx = assign_query.capture_index_for_name("source");
746
747 let mut assignment_map: HashMap<String, Vec<String>> = HashMap::new();
749
750 if let (Some(var_cap), Some(class_cap), Some(source_cap)) = (var_idx, class_idx, source_idx) {
751 let mut cursor = QueryCursor::new();
752 let mut matches = cursor.matches(assign_query, tree.root_node(), source_bytes);
753 while let Some(m) = matches.next() {
754 let mut var_text = String::new();
755 let mut target_text = String::new();
756 for cap in m.captures {
757 if cap.index == var_cap {
758 var_text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
759 } else if cap.index == class_cap || cap.index == source_cap {
760 let t = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
761 if !t.is_empty() {
762 target_text = t;
763 }
764 }
765 }
766 if !var_text.is_empty() && !target_text.is_empty() && var_text != target_text {
767 assignment_map
768 .entry(var_text)
769 .or_default()
770 .push(target_text);
771 }
772 }
773 }
774
775 let mut resolved: HashSet<String> = assertion_identifiers.clone();
777 for _ in 0..2 {
778 let mut additions: HashSet<String> = HashSet::new();
779 for sym in &resolved {
780 if let Some(targets) = assignment_map.get(sym) {
781 for t in targets {
782 additions.insert(t.clone());
783 }
784 }
785 }
786 let before = resolved.len();
787 resolved.extend(additions);
788 if resolved.len() == before {
789 break;
790 }
791 }
792
793 resolved
794}
795
796fn track_new_matches(
800 all_matched: &HashSet<usize>,
801 before: &HashSet<usize>,
802 symbols: &[String],
803 idx_to_symbols: &mut HashMap<usize, HashSet<String>>,
804) {
805 for &new_idx in all_matched.difference(before) {
806 let entry = idx_to_symbols.entry(new_idx).or_default();
807 for s in symbols {
808 entry.insert(s.clone());
809 }
810 }
811}
812
813impl PythonExtractor {
814 pub fn map_test_files_with_imports(
816 &self,
817 production_files: &[String],
818 test_sources: &HashMap<String, String>,
819 scan_root: &Path,
820 l1_exclusive: bool,
821 ) -> Vec<FileMapping> {
822 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
823
824 let canonical_root_for_filter = scan_root.canonicalize().ok();
832 let filtered_production_files: Vec<String> = production_files
833 .iter()
834 .filter(|p| {
835 let check_path = if let Some(ref root) = canonical_root_for_filter {
836 if let Ok(canonical_p) = Path::new(p).canonicalize() {
837 if let Ok(rel) = canonical_p.strip_prefix(root) {
838 rel.to_string_lossy().into_owned()
839 } else {
840 p.to_string()
841 }
842 } else {
843 p.to_string()
844 }
845 } else {
846 p.to_string()
847 };
848 !is_non_sut_helper(&check_path, false)
849 })
850 .cloned()
851 .collect();
852
853 let mut mappings =
855 exspec_core::observe::map_test_files(self, &filtered_production_files, &test_file_list);
856
857 let canonical_root = match scan_root.canonicalize() {
859 Ok(r) => r,
860 Err(_) => return mappings,
861 };
862 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
863 for (idx, prod) in filtered_production_files.iter().enumerate() {
864 if let Ok(canonical) = Path::new(prod).canonicalize() {
865 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
866 }
867 }
868
869 let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
871 .iter()
872 .map(|m| m.test_files.iter().cloned().collect())
873 .collect();
874
875 {
879 let mut stem_to_prod_indices: HashMap<String, Vec<usize>> = HashMap::new();
881 for (idx, prod) in filtered_production_files.iter().enumerate() {
882 if let Some(pstem) = self.production_stem(prod) {
883 stem_to_prod_indices
884 .entry(pstem.to_owned())
885 .or_default()
886 .push(idx);
887 }
888 }
889
890 let l1_core_matched: HashSet<&str> = layer1_tests_per_prod
892 .iter()
893 .flat_map(|s| s.iter().map(|t| t.as_str()))
894 .collect();
895
896 for test_file in &test_file_list {
897 if l1_core_matched.contains(test_file.as_str()) {
899 continue;
900 }
901 if let Some(tstem) = self.test_stem(test_file) {
902 if let Some(prod_indices) = stem_to_prod_indices.get(tstem) {
903 if prod_indices.len() > 1 {
904 continue; }
906 for &idx in prod_indices {
907 if !mappings[idx].test_files.contains(test_file) {
908 mappings[idx].test_files.push(test_file.clone());
909 }
910 }
911 }
912 }
913 }
914 }
915
916 let layer1_extended_tests_per_prod: Vec<HashSet<String>> = mappings
918 .iter()
919 .map(|m| m.test_files.iter().cloned().collect())
920 .collect();
921
922 let l1_matched_tests: HashSet<String> = mappings
924 .iter()
925 .flat_map(|m| m.test_files.iter().cloned())
926 .collect();
927
928 for (test_file, source) in test_sources {
930 if l1_exclusive && l1_matched_tests.contains(test_file.as_str()) {
931 continue;
932 }
933 let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
934 let from_file = Path::new(test_file);
935 let mut all_matched = HashSet::<usize>::new();
937 let mut idx_to_symbols: HashMap<usize, HashSet<String>> = HashMap::new();
939 let mut direct_import_indices: HashSet<usize> = HashSet::new();
942
943 for import in &imports {
944 let is_bare_relative = (import.module_specifier == "./"
948 || import.module_specifier.ends_with('/'))
949 && import
950 .module_specifier
951 .trim_end_matches('/')
952 .chars()
953 .all(|c| c == '.');
954
955 let specifier = if is_bare_relative {
956 let prefix =
957 &import.module_specifier[..import.module_specifier.len().saturating_sub(1)];
958 for sym in &import.symbols {
959 let sym_specifier = format!("{prefix}/{sym}");
960 if let Some(resolved) = exspec_core::observe::resolve_import_path(
961 self,
962 &sym_specifier,
963 from_file,
964 &canonical_root,
965 ) {
966 if self.is_barrel_file(&resolved)
968 && l1_matched_tests.contains(test_file.as_str())
969 {
970 continue;
971 }
972 let sym_slice = &[sym.clone()];
973 let before = all_matched.clone();
974 exspec_core::observe::collect_import_matches(
975 self,
976 &resolved,
977 sym_slice,
978 &canonical_to_idx,
979 &mut all_matched,
980 &canonical_root,
981 );
982 track_new_matches(
983 &all_matched,
984 &before,
985 sym_slice,
986 &mut idx_to_symbols,
987 );
988 if !self.is_barrel_file(&resolved) {
993 for &idx in all_matched.difference(&before) {
994 direct_import_indices.insert(idx);
995 }
996 }
997 }
998 }
999 continue;
1000 } else {
1001 import.module_specifier.clone()
1002 };
1003
1004 if let Some(resolved) = exspec_core::observe::resolve_import_path(
1005 self,
1006 &specifier,
1007 from_file,
1008 &canonical_root,
1009 ) {
1010 if self.is_barrel_file(&resolved)
1012 && l1_matched_tests.contains(test_file.as_str())
1013 {
1014 continue;
1015 }
1016 let before = all_matched.clone();
1017 exspec_core::observe::collect_import_matches(
1018 self,
1019 &resolved,
1020 &import.symbols,
1021 &canonical_to_idx,
1022 &mut all_matched,
1023 &canonical_root,
1024 );
1025 track_new_matches(&all_matched, &before, &import.symbols, &mut idx_to_symbols);
1026 let is_direct = !self.is_barrel_file(&resolved);
1028 if is_direct {
1029 for &idx in all_matched.difference(&before) {
1030 direct_import_indices.insert(idx);
1031 }
1032 }
1033 }
1034 }
1035
1036 let abs_specifiers = self.extract_all_import_specifiers(source);
1038 for (specifier, symbols) in &abs_specifiers {
1039 let base = canonical_root.join(specifier);
1040 let resolved = exspec_core::observe::resolve_absolute_base_to_file(
1041 self,
1042 &base,
1043 &canonical_root,
1044 )
1045 .or_else(|| {
1046 let src_base = canonical_root.join("src").join(specifier);
1047 exspec_core::observe::resolve_absolute_base_to_file(
1048 self,
1049 &src_base,
1050 &canonical_root,
1051 )
1052 });
1053 if let Some(resolved) = resolved {
1054 if self.is_barrel_file(&resolved)
1056 && l1_matched_tests.contains(test_file.as_str())
1057 {
1058 continue;
1059 }
1060 let is_direct = !self.is_barrel_file(&resolved);
1063 let before = all_matched.clone();
1064 exspec_core::observe::collect_import_matches(
1065 self,
1066 &resolved,
1067 symbols,
1068 &canonical_to_idx,
1069 &mut all_matched,
1070 &canonical_root,
1071 );
1072 track_new_matches(&all_matched, &before, symbols, &mut idx_to_symbols);
1073 if is_direct {
1075 for &idx in all_matched.difference(&before) {
1076 direct_import_indices.insert(idx);
1077 }
1078 }
1079 }
1080 }
1081
1082 let asserted_imports = extract_assertion_referenced_imports(source);
1084 let final_indices: HashSet<usize> = if asserted_imports.is_empty() {
1085 all_matched.clone()
1087 } else {
1088 let asserted_matched: HashSet<usize> = all_matched
1090 .iter()
1091 .copied()
1092 .filter(|idx| {
1093 idx_to_symbols
1094 .get(idx)
1095 .map(|syms| syms.iter().any(|s| asserted_imports.contains(s)))
1096 .unwrap_or(false)
1097 })
1098 .collect();
1099 if asserted_matched.is_empty() {
1100 all_matched.clone()
1102 } else {
1103 let mut final_set = asserted_matched;
1105 final_set.extend(direct_import_indices.intersection(&all_matched).copied());
1106 final_set
1107 }
1108 };
1109
1110 for idx in final_indices {
1111 if !mappings[idx].test_files.contains(test_file) {
1112 mappings[idx].test_files.push(test_file.clone());
1113 }
1114 }
1115 }
1116
1117 for (i, mapping) in mappings.iter_mut().enumerate() {
1120 let has_layer1 = !layer1_extended_tests_per_prod[i].is_empty();
1121 if !has_layer1 && !mapping.test_files.is_empty() {
1122 mapping.strategy = MappingStrategy::ImportTracing;
1123 }
1124 }
1125
1126 mappings
1127 }
1128}
1129
1130#[cfg(test)]
1135mod tests {
1136 use super::*;
1137 use std::path::PathBuf;
1138
1139 #[test]
1143 fn py_stem_01_test_prefix() {
1144 let extractor = PythonExtractor::new();
1148 let result = extractor.test_stem("tests/test_user.py");
1149 assert_eq!(result, Some("user"));
1150 }
1151
1152 #[test]
1156 fn py_stem_02_test_suffix() {
1157 let extractor = PythonExtractor::new();
1161 let result = extractor.test_stem("tests/user_test.py");
1162 assert_eq!(result, Some("user"));
1163 }
1164
1165 #[test]
1169 fn py_stem_03_test_prefix_multi_segment() {
1170 let extractor = PythonExtractor::new();
1174 let result = extractor.test_stem("tests/test_user_service.py");
1175 assert_eq!(result, Some("user_service"));
1176 }
1177
1178 #[test]
1182 fn py_stem_04_production_stem_regular() {
1183 let extractor = PythonExtractor::new();
1187 let result = extractor.production_stem("src/user.py");
1188 assert_eq!(result, Some("user"));
1189 }
1190
1191 #[test]
1195 fn py_stem_05_production_stem_init() {
1196 let extractor = PythonExtractor::new();
1200 let result = extractor.production_stem("src/__init__.py");
1201 assert_eq!(result, None);
1202 }
1203
1204 #[test]
1208 fn py_stem_06_production_stem_test_file() {
1209 let extractor = PythonExtractor::new();
1213 let result = extractor.production_stem("tests/test_user.py");
1214 assert_eq!(result, None);
1215 }
1216
1217 #[test]
1221 fn py_helper_01_conftest() {
1222 let extractor = PythonExtractor::new();
1226 assert!(extractor.is_non_sut_helper("tests/conftest.py", false));
1227 }
1228
1229 #[test]
1233 fn py_helper_02_constants() {
1234 let extractor = PythonExtractor::new();
1238 assert!(extractor.is_non_sut_helper("src/constants.py", false));
1239 }
1240
1241 #[test]
1245 fn py_helper_03_init() {
1246 let extractor = PythonExtractor::new();
1250 assert!(extractor.is_non_sut_helper("src/__init__.py", false));
1251 }
1252
1253 #[test]
1257 fn py_helper_04_utils_under_tests_dir() {
1258 let extractor = PythonExtractor::new();
1262 assert!(extractor.is_non_sut_helper("tests/utils.py", false));
1263 }
1264
1265 #[test]
1269 fn py_helper_05_models_is_not_helper() {
1270 let extractor = PythonExtractor::new();
1274 assert!(!extractor.is_non_sut_helper("src/models.py", false));
1275 }
1276
1277 #[test]
1281 fn py_helper_06_tests_common_helper_despite_known_production() {
1282 let extractor = PythonExtractor::new();
1286 assert!(extractor.is_non_sut_helper("tests/common.py", true));
1287 }
1288
1289 #[test]
1293 fn py_helper_07_tests_subdirectory_helper() {
1294 let extractor = PythonExtractor::new();
1298 assert!(extractor.is_non_sut_helper("tests/testserver/server.py", true));
1299 }
1300
1301 #[test]
1305 fn py_helper_08_tests_compat_helper() {
1306 let extractor = PythonExtractor::new();
1310 assert!(extractor.is_non_sut_helper("tests/compat.py", false));
1311 }
1312
1313 #[test]
1317 fn py_helper_09_deep_nested_test_dir_helper() {
1318 let extractor = PythonExtractor::new();
1322 assert!(extractor.is_non_sut_helper("tests/fixtures/data.py", false));
1323 }
1324
1325 #[test]
1329 fn py_helper_10_tests_in_filename_not_helper() {
1330 let extractor = PythonExtractor::new();
1334 assert!(!extractor.is_non_sut_helper("src/tests.py", false));
1335 }
1336
1337 #[test]
1341 fn py_helper_11_test_singular_dir_helper() {
1342 let extractor = PythonExtractor::new();
1346 assert!(extractor.is_non_sut_helper("test/helpers.py", true));
1347 }
1348
1349 #[test]
1353 fn py_barrel_01_init_is_barrel() {
1354 let extractor = PythonExtractor::new();
1358 assert!(extractor.is_barrel_file("src/mypackage/__init__.py"));
1359 }
1360
1361 #[test]
1365 fn py_func_01_top_level_function() {
1366 let source = r#"
1368def create_user():
1369 pass
1370"#;
1371 let extractor = PythonExtractor::new();
1373 let result = extractor.extract_production_functions(source, "src/users.py");
1374
1375 let func = result.iter().find(|f| f.name == "create_user");
1377 assert!(func.is_some(), "create_user not found in {:?}", result);
1378 let func = func.unwrap();
1379 assert_eq!(func.class_name, None);
1380 }
1381
1382 #[test]
1386 fn py_func_02_class_method() {
1387 let source = r#"
1389class User:
1390 def save(self):
1391 pass
1392"#;
1393 let extractor = PythonExtractor::new();
1395 let result = extractor.extract_production_functions(source, "src/models.py");
1396
1397 let method = result.iter().find(|f| f.name == "save");
1399 assert!(method.is_some(), "save not found in {:?}", result);
1400 let method = method.unwrap();
1401 assert_eq!(method.class_name, Some("User".to_string()));
1402 }
1403
1404 #[test]
1408 fn py_func_03_decorated_function() {
1409 let source = r#"
1411import functools
1412
1413def my_decorator(func):
1414 @functools.wraps(func)
1415 def wrapper(*args, **kwargs):
1416 return func(*args, **kwargs)
1417 return wrapper
1418
1419@my_decorator
1420def endpoint():
1421 pass
1422"#;
1423 let extractor = PythonExtractor::new();
1425 let result = extractor.extract_production_functions(source, "src/views.py");
1426
1427 let func = result.iter().find(|f| f.name == "endpoint");
1429 assert!(func.is_some(), "endpoint not found in {:?}", result);
1430 }
1431
1432 #[test]
1436 fn py_imp_01_relative_import_from_dot() {
1437 let source = "from .models import User\n";
1439
1440 let extractor = PythonExtractor::new();
1442 let result = extractor.extract_imports(source, "tests/test_user.py");
1443
1444 let imp = result.iter().find(|i| i.module_specifier == "./models");
1446 assert!(
1447 imp.is_some(),
1448 "import from ./models not found in {:?}",
1449 result
1450 );
1451 let imp = imp.unwrap();
1452 assert!(
1453 imp.symbols.contains(&"User".to_string()),
1454 "User not in symbols: {:?}",
1455 imp.symbols
1456 );
1457 }
1458
1459 #[test]
1463 fn py_imp_02_relative_import_two_dots() {
1464 let source = "from ..utils import helper\n";
1466
1467 let extractor = PythonExtractor::new();
1469 let result = extractor.extract_imports(source, "tests/unit/test_something.py");
1470
1471 let imp = result.iter().find(|i| i.module_specifier == "../utils");
1473 assert!(
1474 imp.is_some(),
1475 "import from ../utils not found in {:?}",
1476 result
1477 );
1478 let imp = imp.unwrap();
1479 assert!(
1480 imp.symbols.contains(&"helper".to_string()),
1481 "helper not in symbols: {:?}",
1482 imp.symbols
1483 );
1484 }
1485
1486 #[test]
1490 fn py_imp_03_absolute_import_dotted() {
1491 let source = "from myapp.models import User\n";
1493
1494 let extractor = PythonExtractor::new();
1496 let result = extractor.extract_all_import_specifiers(source);
1497
1498 let entry = result.iter().find(|(spec, _)| spec == "myapp/models");
1500 assert!(entry.is_some(), "myapp/models not found in {:?}", result);
1501 let (_, symbols) = entry.unwrap();
1502 assert!(
1503 symbols.contains(&"User".to_string()),
1504 "User not in symbols: {:?}",
1505 symbols
1506 );
1507 }
1508
1509 #[test]
1513 fn py_imp_04_plain_import_skipped() {
1514 let source = "import os\n";
1516
1517 let extractor = PythonExtractor::new();
1519 let result = extractor.extract_all_import_specifiers(source);
1520
1521 let os_entry = result.iter().find(|(spec, _)| spec == "os");
1523 assert!(
1524 os_entry.is_some(),
1525 "plain 'import os' should be included as bare import, got {:?}",
1526 result
1527 );
1528 let (_, symbols) = os_entry.unwrap();
1529 assert!(
1530 symbols.is_empty(),
1531 "expected empty symbols for bare import, got {:?}",
1532 symbols
1533 );
1534 }
1535
1536 #[test]
1540 fn py_imp_05_from_dot_import_name() {
1541 let source = "from . import views\n";
1543
1544 let extractor = PythonExtractor::new();
1546 let result = extractor.extract_imports(source, "tests/test_app.py");
1547
1548 let imp = result.iter().find(|i| i.module_specifier == "./views");
1550 assert!(imp.is_some(), "./views not found in {:?}", result);
1551 let imp = imp.unwrap();
1552 assert!(
1553 imp.symbols.contains(&"views".to_string()),
1554 "views not in symbols: {:?}",
1555 imp.symbols
1556 );
1557 }
1558
1559 #[test]
1563 fn py_import_01_bare_import_simple() {
1564 let source = "import httpx\n";
1566
1567 let extractor = PythonExtractor::new();
1569 let result = extractor.extract_all_import_specifiers(source);
1570
1571 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1573 assert!(
1574 entry.is_some(),
1575 "httpx not found in {:?}; bare import should be included",
1576 result
1577 );
1578 let (_, symbols) = entry.unwrap();
1579 assert!(
1580 symbols.is_empty(),
1581 "expected empty symbols for bare import, got {:?}",
1582 symbols
1583 );
1584 }
1585
1586 #[test]
1590 fn py_import_01b_bare_import_attribute_access_narrowing() {
1591 let source = "import httpx\nhttpx.Client()\nhttpx.get('/api')\n";
1593
1594 let extractor = PythonExtractor::new();
1596 let result = extractor.extract_all_import_specifiers(source);
1597
1598 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1600 assert!(entry.is_some(), "httpx not found in {:?}", result);
1601 let (_, symbols) = entry.unwrap();
1602 assert!(
1603 symbols.contains(&"Client".to_string()),
1604 "expected Client in symbols, got {:?}",
1605 symbols
1606 );
1607 assert!(
1608 symbols.contains(&"get".to_string()),
1609 "expected get in symbols, got {:?}",
1610 symbols
1611 );
1612 }
1613
1614 #[test]
1621 fn py_import_02a_dotted_bare_import_attribute_fallback() {
1622 let source = "import os.path\nos.path.join('/a', 'b')\n";
1624
1625 let extractor = PythonExtractor::new();
1627 let result = extractor.extract_all_import_specifiers(source);
1628
1629 let entry = result.iter().find(|(spec, _)| spec == "os/path");
1632 assert!(entry.is_some(), "os/path not found in {:?}", result);
1633 let (_, symbols) = entry.unwrap();
1634 assert!(
1635 symbols.is_empty(),
1636 "expected empty symbols for dotted bare import (intentional fallback), got {:?}",
1637 symbols
1638 );
1639 }
1640
1641 #[test]
1645 fn py_import_02_bare_import_dotted() {
1646 let source = "import os.path\n";
1648
1649 let extractor = PythonExtractor::new();
1651 let result = extractor.extract_all_import_specifiers(source);
1652
1653 let entry = result.iter().find(|(spec, _)| spec == "os/path");
1655 assert!(
1656 entry.is_some(),
1657 "os/path not found in {:?}; dotted bare import should be converted",
1658 result
1659 );
1660 let (_, symbols) = entry.unwrap();
1661 assert!(
1662 symbols.is_empty(),
1663 "expected empty symbols for dotted bare import, got {:?}",
1664 symbols
1665 );
1666 }
1667
1668 #[test]
1673 fn py_import_03_from_import_regression() {
1674 let source = "from httpx import Client\n";
1676
1677 let extractor = PythonExtractor::new();
1679 let result = extractor.extract_all_import_specifiers(source);
1680
1681 let entry = result.iter().find(|(spec, _)| spec == "httpx");
1683 assert!(entry.is_some(), "httpx not found in {:?}", result);
1684 let (_, symbols) = entry.unwrap();
1685 assert!(
1686 symbols.contains(&"Client".to_string()),
1687 "Client not in symbols: {:?}",
1688 symbols
1689 );
1690 }
1691
1692 #[test]
1697 fn py_barrel_02_re_export_named() {
1698 let source = "from .module import Foo\n";
1700
1701 let extractor = PythonExtractor::new();
1703 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1704
1705 let entry = result.iter().find(|e| e.from_specifier == "./module");
1707 assert!(entry.is_some(), "./module not found in {:?}", result);
1708 let entry = entry.unwrap();
1709 assert!(
1710 entry.symbols.contains(&"Foo".to_string()),
1711 "Foo not in symbols: {:?}",
1712 entry.symbols
1713 );
1714 }
1715
1716 #[test]
1720 fn py_barrel_03_all_exports_symbol_present() {
1721 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1724 .parent()
1725 .unwrap()
1726 .parent()
1727 .unwrap()
1728 .join("tests/fixtures/python/observe/barrel/__init__.py");
1729
1730 let extractor = PythonExtractor::new();
1732 let symbols = vec!["Foo".to_string()];
1733 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1734
1735 assert!(
1737 result,
1738 "expected file_exports_any_symbol to return true for Foo"
1739 );
1740 }
1741
1742 #[test]
1746 fn py_barrel_04_all_exports_symbol_absent() {
1747 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1749 .parent()
1750 .unwrap()
1751 .parent()
1752 .unwrap()
1753 .join("tests/fixtures/python/observe/barrel/__init__.py");
1754
1755 let extractor = PythonExtractor::new();
1757 let symbols = vec!["Bar".to_string()];
1758 let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1759
1760 assert!(
1762 !result,
1763 "expected file_exports_any_symbol to return false for Bar"
1764 );
1765 }
1766
1767 #[test]
1771 fn py_barrel_05_re_export_wildcard() {
1772 let source = "from .module import *\n";
1774
1775 let extractor = PythonExtractor::new();
1777 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1778
1779 let entry = result.iter().find(|e| e.from_specifier == "./module");
1781 assert!(entry.is_some(), "./module not found in {:?}", result);
1782 let entry = entry.unwrap();
1783 assert!(entry.wildcard, "expected wildcard=true, got {:?}", entry);
1784 assert!(
1785 entry.symbols.is_empty(),
1786 "expected empty symbols for wildcard, got {:?}",
1787 entry.symbols
1788 );
1789 }
1790
1791 #[test]
1795 fn py_barrel_06_re_export_named_multi_symbol() {
1796 let source = "from .module import Foo, Bar\n";
1798
1799 let extractor = PythonExtractor::new();
1801 let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1802
1803 let entry = result.iter().find(|e| e.from_specifier == "./module");
1805 assert!(entry.is_some(), "./module not found in {:?}", result);
1806 let entry = entry.unwrap();
1807 assert!(
1808 !entry.wildcard,
1809 "expected wildcard=false for named re-export, got {:?}",
1810 entry
1811 );
1812 assert!(
1813 entry.symbols.contains(&"Foo".to_string()),
1814 "Foo not in symbols: {:?}",
1815 entry.symbols
1816 );
1817 assert!(
1818 entry.symbols.contains(&"Bar".to_string()),
1819 "Bar not in symbols: {:?}",
1820 entry.symbols
1821 );
1822 }
1823
1824 #[test]
1830 fn py_barrel_07_e2e_wildcard_barrel_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(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1841 let tests_dir = dir.path().join("tests");
1843 std::fs::create_dir_all(&tests_dir).unwrap();
1844 std::fs::write(
1845 tests_dir.join("test_foo.py"),
1846 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1847 )
1848 .unwrap();
1849
1850 let extractor = PythonExtractor::new();
1851 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1852 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1853 let test_source = std::fs::read_to_string(&test_path).unwrap();
1854
1855 let production_files = vec![module_path.clone()];
1856 let test_sources: HashMap<String, String> =
1857 [(test_path.clone(), test_source)].into_iter().collect();
1858
1859 let result = extractor.map_test_files_with_imports(
1861 &production_files,
1862 &test_sources,
1863 dir.path(),
1864 false,
1865 );
1866
1867 let mapping = result.iter().find(|m| m.production_file == module_path);
1869 assert!(
1870 mapping.is_some(),
1871 "module.py not found in mappings: {:?}",
1872 result
1873 );
1874 let mapping = mapping.unwrap();
1875 assert!(
1876 mapping.test_files.contains(&test_path),
1877 "test_foo.py not matched to module.py: {:?}",
1878 mapping.test_files
1879 );
1880 }
1881
1882 #[test]
1888 fn py_barrel_08_e2e_named_barrel_mapped() {
1889 use tempfile::TempDir;
1890
1891 let dir = TempDir::new().unwrap();
1892 let pkg = dir.path().join("pkg");
1893 std::fs::create_dir_all(&pkg).unwrap();
1894
1895 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
1897 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
1899 let tests_dir = dir.path().join("tests");
1901 std::fs::create_dir_all(&tests_dir).unwrap();
1902 std::fs::write(
1903 tests_dir.join("test_foo.py"),
1904 "from pkg import Foo\n\ndef test_foo():\n assert Foo()\n",
1905 )
1906 .unwrap();
1907
1908 let extractor = PythonExtractor::new();
1909 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1910 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1911 let test_source = std::fs::read_to_string(&test_path).unwrap();
1912
1913 let production_files = vec![module_path.clone()];
1914 let test_sources: HashMap<String, String> =
1915 [(test_path.clone(), test_source)].into_iter().collect();
1916
1917 let result = extractor.map_test_files_with_imports(
1919 &production_files,
1920 &test_sources,
1921 dir.path(),
1922 false,
1923 );
1924
1925 let mapping = result.iter().find(|m| m.production_file == module_path);
1927 assert!(
1928 mapping.is_some(),
1929 "module.py not found in mappings: {:?}",
1930 result
1931 );
1932 let mapping = mapping.unwrap();
1933 assert!(
1934 mapping.test_files.contains(&test_path),
1935 "test_foo.py not matched to module.py: {:?}",
1936 mapping.test_files
1937 );
1938 }
1939
1940 #[test]
1946 fn py_barrel_09_e2e_wildcard_barrel_non_exported_not_mapped() {
1947 use tempfile::TempDir;
1948
1949 let dir = TempDir::new().unwrap();
1950 let pkg = dir.path().join("pkg");
1951 std::fs::create_dir_all(&pkg).unwrap();
1952
1953 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1955 std::fs::write(
1957 pkg.join("module.py"),
1958 "__all__ = [\"Foo\"]\n\nclass Foo:\n pass\n\nclass NonExistent:\n pass\n",
1959 )
1960 .unwrap();
1961 let tests_dir = dir.path().join("tests");
1963 std::fs::create_dir_all(&tests_dir).unwrap();
1964 std::fs::write(
1965 tests_dir.join("test_nonexistent.py"),
1966 "from pkg import NonExistent\n\ndef test_ne():\n assert NonExistent()\n",
1967 )
1968 .unwrap();
1969
1970 let extractor = PythonExtractor::new();
1971 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1972 let test_path = tests_dir
1973 .join("test_nonexistent.py")
1974 .to_string_lossy()
1975 .into_owned();
1976 let test_source = std::fs::read_to_string(&test_path).unwrap();
1977
1978 let production_files = vec![module_path.clone()];
1979 let test_sources: HashMap<String, String> =
1980 [(test_path.clone(), test_source)].into_iter().collect();
1981
1982 let result = extractor.map_test_files_with_imports(
1984 &production_files,
1985 &test_sources,
1986 dir.path(),
1987 false,
1988 );
1989
1990 let mapping = result.iter().find(|m| m.production_file == module_path);
1993 if let Some(mapping) = mapping {
1994 assert!(
1995 !mapping.test_files.contains(&test_path),
1996 "test_nonexistent.py should NOT be matched to module.py: {:?}",
1997 mapping.test_files
1998 );
1999 }
2000 }
2002
2003 #[test]
2007 fn py_e2e_01_layer1_stem_match() {
2008 let extractor = PythonExtractor::new();
2010 let production_files = vec!["e2e_pkg/models.py".to_string()];
2011 let test_sources: HashMap<String, String> =
2012 [("e2e_pkg/test_models.py".to_string(), "".to_string())]
2013 .into_iter()
2014 .collect();
2015
2016 let scan_root = PathBuf::from(".");
2018 let result = extractor.map_test_files_with_imports(
2019 &production_files,
2020 &test_sources,
2021 &scan_root,
2022 false,
2023 );
2024
2025 let mapping = result
2027 .iter()
2028 .find(|m| m.production_file == "e2e_pkg/models.py");
2029 assert!(
2030 mapping.is_some(),
2031 "models.py not found in mappings: {:?}",
2032 result
2033 );
2034 let mapping = mapping.unwrap();
2035 assert!(
2036 mapping
2037 .test_files
2038 .contains(&"e2e_pkg/test_models.py".to_string()),
2039 "test_models.py not in test_files: {:?}",
2040 mapping.test_files
2041 );
2042 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2043 }
2044
2045 #[test]
2049 fn py_e2e_02_layer2_import_tracing() {
2050 let extractor = PythonExtractor::new();
2052
2053 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2054 .parent()
2055 .unwrap()
2056 .parent()
2057 .unwrap()
2058 .join("tests/fixtures/python/observe/e2e_pkg");
2059
2060 let views_path = fixture_root.join("views.py").to_string_lossy().into_owned();
2061 let test_views_path = fixture_root
2062 .join("tests/test_views.py")
2063 .to_string_lossy()
2064 .into_owned();
2065
2066 let test_source =
2067 std::fs::read_to_string(fixture_root.join("tests/test_views.py")).unwrap_or_default();
2068
2069 let production_files = vec![views_path.clone()];
2070 let test_sources: HashMap<String, String> = [(test_views_path.clone(), test_source)]
2071 .into_iter()
2072 .collect();
2073
2074 let result = extractor.map_test_files_with_imports(
2076 &production_files,
2077 &test_sources,
2078 &fixture_root,
2079 false,
2080 );
2081
2082 let mapping = result.iter().find(|m| m.production_file == views_path);
2084 assert!(
2085 mapping.is_some(),
2086 "views.py not found in mappings: {:?}",
2087 result
2088 );
2089 let mapping = mapping.unwrap();
2090 assert!(
2091 mapping.test_files.contains(&test_views_path),
2092 "test_views.py not matched to views.py: {:?}",
2093 mapping.test_files
2094 );
2095 }
2096
2097 #[test]
2101 fn py_e2e_03_conftest_excluded_as_helper() {
2102 let extractor = PythonExtractor::new();
2104 let production_files = vec!["e2e_pkg/models.py".to_string()];
2105 let test_sources: HashMap<String, String> = [
2106 ("e2e_pkg/tests/test_models.py".to_string(), "".to_string()),
2107 (
2108 "e2e_pkg/tests/conftest.py".to_string(),
2109 "import pytest\n".to_string(),
2110 ),
2111 ]
2112 .into_iter()
2113 .collect();
2114
2115 let scan_root = PathBuf::from(".");
2117 let result = extractor.map_test_files_with_imports(
2118 &production_files,
2119 &test_sources,
2120 &scan_root,
2121 false,
2122 );
2123
2124 for mapping in &result {
2126 assert!(
2127 !mapping.test_files.iter().any(|f| f.contains("conftest.py")),
2128 "conftest.py should not appear in mappings: {:?}",
2129 mapping
2130 );
2131 }
2132 }
2133
2134 struct ImportTestResult {
2139 mappings: Vec<FileMapping>,
2140 prod_path: String,
2141 test_path: String,
2142 _tmp: tempfile::TempDir,
2143 }
2144
2145 fn run_import_test(
2149 prod_rel: &str,
2150 prod_content: &str,
2151 test_rel: &str,
2152 test_content: &str,
2153 extra_files: &[(&str, &str)],
2154 ) -> ImportTestResult {
2155 let tmp = tempfile::tempdir().unwrap();
2156
2157 for (rel, content) in extra_files {
2159 let path = tmp.path().join(rel);
2160 if let Some(parent) = path.parent() {
2161 std::fs::create_dir_all(parent).unwrap();
2162 }
2163 std::fs::write(&path, content).unwrap();
2164 }
2165
2166 let prod_abs = tmp.path().join(prod_rel);
2168 if let Some(parent) = prod_abs.parent() {
2169 std::fs::create_dir_all(parent).unwrap();
2170 }
2171 std::fs::write(&prod_abs, prod_content).unwrap();
2172
2173 let test_abs = tmp.path().join(test_rel);
2175 if let Some(parent) = test_abs.parent() {
2176 std::fs::create_dir_all(parent).unwrap();
2177 }
2178 std::fs::write(&test_abs, test_content).unwrap();
2179
2180 let extractor = PythonExtractor::new();
2181 let prod_path = prod_abs.to_string_lossy().into_owned();
2182 let test_path = test_abs.to_string_lossy().into_owned();
2183 let production_files = vec![prod_path.clone()];
2184 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2185 .into_iter()
2186 .collect();
2187
2188 let mappings = extractor.map_test_files_with_imports(
2189 &production_files,
2190 &test_sources,
2191 tmp.path(),
2192 false,
2193 );
2194
2195 ImportTestResult {
2196 mappings,
2197 prod_path,
2198 test_path,
2199 _tmp: tmp,
2200 }
2201 }
2202
2203 #[test]
2207 fn py_abs_01_absolute_import_nested_module() {
2208 let r = run_import_test(
2211 "models/cars.py",
2212 "class Car:\n pass\n",
2213 "tests/unit/test_car.py",
2214 "from models.cars import Car\n\ndef test_car():\n pass\n",
2215 &[],
2216 );
2217
2218 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2220 assert!(
2221 mapping.is_some(),
2222 "models/cars.py not found in mappings: {:?}",
2223 r.mappings
2224 );
2225 let mapping = mapping.unwrap();
2226 assert!(
2227 mapping.test_files.contains(&r.test_path),
2228 "test_car.py not in test_files for models/cars.py: {:?}",
2229 mapping.test_files
2230 );
2231 assert_eq!(
2232 mapping.strategy,
2233 MappingStrategy::ImportTracing,
2234 "expected ImportTracing strategy, got {:?}",
2235 mapping.strategy
2236 );
2237 }
2238
2239 #[test]
2243 fn py_abs_02_absolute_import_utils_module() {
2244 let r = run_import_test(
2247 "utils/publish_state.py",
2248 "class PublishState:\n pass\n",
2249 "tests/test_pub.py",
2250 "from utils.publish_state import PublishState\n\ndef test_pub():\n pass\n",
2251 &[],
2252 );
2253
2254 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2256 assert!(
2257 mapping.is_some(),
2258 "utils/publish_state.py not found in mappings: {:?}",
2259 r.mappings
2260 );
2261 let mapping = mapping.unwrap();
2262 assert!(
2263 mapping.test_files.contains(&r.test_path),
2264 "test_pub.py not in test_files for utils/publish_state.py: {:?}",
2265 mapping.test_files
2266 );
2267 assert_eq!(
2268 mapping.strategy,
2269 MappingStrategy::ImportTracing,
2270 "expected ImportTracing strategy, got {:?}",
2271 mapping.strategy
2272 );
2273 }
2274
2275 #[test]
2279 fn py_abs_03_relative_import_still_resolves() {
2280 let r = run_import_test(
2284 "pkg/models.py",
2285 "class X:\n pass\n",
2286 "pkg/test_something.py",
2287 "from .models import X\n\ndef test_x():\n pass\n",
2288 &[],
2289 );
2290
2291 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2293 assert!(
2294 mapping.is_some(),
2295 "pkg/models.py not found in mappings: {:?}",
2296 r.mappings
2297 );
2298 let mapping = mapping.unwrap();
2299 assert!(
2300 mapping.test_files.contains(&r.test_path),
2301 "test_something.py not in test_files for pkg/models.py: {:?}",
2302 mapping.test_files
2303 );
2304 }
2305
2306 #[test]
2310 fn py_stem_07_production_stem_single_underscore_prefix() {
2311 let extractor = PythonExtractor::new();
2315 let result = extractor.production_stem("httpx/_decoders.py");
2316 assert_eq!(result, Some("decoders"));
2317 }
2318
2319 #[test]
2323 fn py_stem_08_production_stem_double_underscore_strips_one() {
2324 let extractor = PythonExtractor::new();
2328 let result = extractor.production_stem("httpx/__version__.py");
2329 assert_eq!(result, Some("_version"));
2330 }
2331
2332 #[test]
2336 fn py_stem_09_production_stem_no_prefix_regression() {
2337 let extractor = PythonExtractor::new();
2341 let result = extractor.production_stem("httpx/decoders.py");
2342 assert_eq!(result, Some("decoders"));
2343 }
2344
2345 #[test]
2349 fn py_stem_10_production_stem_triple_underscore() {
2350 let extractor = PythonExtractor::new();
2354 let result = extractor.production_stem("pkg/___triple.py");
2355 assert_eq!(result, Some("__triple"));
2356 }
2357
2358 #[test]
2362 fn py_stem_11_production_stem_prefix_and_suffix_chained() {
2363 let extractor = PythonExtractor::new();
2367 let result = extractor.production_stem("pkg/___foo__.py");
2368 assert_eq!(result, Some("__foo"));
2369 }
2370
2371 #[test]
2375 fn py_stem_12_production_stem_dunder_prefix_and_suffix() {
2376 let extractor = PythonExtractor::new();
2380 let result = extractor.production_stem("pkg/__foo__.py");
2381 assert_eq!(result, Some("_foo"));
2382 }
2383
2384 #[test]
2388 fn py_stem_13_tests_file_with_parent_dir() {
2389 let result = test_stem("app/tests.py");
2393 assert_eq!(result, Some("app"));
2394 }
2395
2396 #[test]
2400 fn py_stem_14_tests_file_with_nested_parent_dir() {
2401 let result = test_stem("tests/aggregation/tests.py");
2405 assert_eq!(result, Some("aggregation"));
2406 }
2407
2408 #[test]
2412 fn py_stem_15_tests_file_no_parent_dir() {
2413 let result = test_stem("tests.py");
2417 assert_eq!(result, None);
2418 }
2419
2420 #[test]
2424 fn py_stem_16_production_stem_excludes_tests_file() {
2425 let result = production_stem("app/tests.py");
2429 assert_eq!(result, None);
2430 }
2431
2432 #[test]
2436 fn py_srclayout_01_src_layout_absolute_import_resolved() {
2437 let r = run_import_test(
2440 "src/mypackage/sessions.py",
2441 "class Session:\n pass\n",
2442 "tests/test_sessions.py",
2443 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2444 &[("src/mypackage/__init__.py", "")],
2445 );
2446
2447 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2452 assert!(
2453 mapping.is_some(),
2454 "src/mypackage/sessions.py not found in mappings: {:?}",
2455 r.mappings
2456 );
2457 let mapping = mapping.unwrap();
2458 assert!(
2459 mapping.test_files.contains(&r.test_path),
2460 "test_sessions.py not in test_files for sessions.py (src/ layout): {:?}",
2461 mapping.test_files
2462 );
2463 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2464 }
2465
2466 #[test]
2470 fn py_srclayout_02_non_src_layout_regression() {
2471 let r = run_import_test(
2474 "mypackage/sessions.py",
2475 "class Session:\n pass\n",
2476 "tests/test_sessions.py",
2477 "from mypackage.sessions import Session\n\ndef test_session():\n pass\n",
2478 &[],
2479 );
2480
2481 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2486 assert!(
2487 mapping.is_some(),
2488 "mypackage/sessions.py not found in mappings: {:?}",
2489 r.mappings
2490 );
2491 let mapping = mapping.unwrap();
2492 assert!(
2493 mapping.test_files.contains(&r.test_path),
2494 "test_sessions.py not in test_files for sessions.py (non-src layout): {:?}",
2495 mapping.test_files
2496 );
2497 assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2498 }
2499
2500 #[test]
2504 fn py_abs_04_nonexistent_absolute_import_skipped() {
2505 let r = run_import_test(
2509 "models/real.py",
2510 "class Real:\n pass\n",
2511 "tests/test_missing.py",
2512 "from nonexistent.module import X\n\ndef test_x():\n pass\n",
2513 &[],
2514 );
2515
2516 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2518 if let Some(mapping) = mapping {
2519 assert!(
2520 !mapping.test_files.contains(&r.test_path),
2521 "test_missing.py should NOT be mapped to models/real.py: {:?}",
2522 mapping.test_files
2523 );
2524 }
2525 }
2527
2528 #[test]
2532 fn py_abs_05_mixed_absolute_and_relative_imports() {
2533 let tmp = tempfile::tempdir().unwrap();
2537 let models_dir = tmp.path().join("models");
2538 let tests_dir = tmp.path().join("tests");
2539 std::fs::create_dir_all(&models_dir).unwrap();
2540 std::fs::create_dir_all(&tests_dir).unwrap();
2541
2542 let cars_py = models_dir.join("cars.py");
2543 std::fs::write(&cars_py, "class Car:\n pass\n").unwrap();
2544
2545 let helpers_py = tests_dir.join("helpers.py");
2546 std::fs::write(&helpers_py, "def setup():\n pass\n").unwrap();
2547
2548 let test_py = tests_dir.join("test_mixed.py");
2549 let test_source =
2550 "from models.cars import Car\nfrom .helpers import setup\n\ndef test_mixed():\n pass\n";
2551 std::fs::write(&test_py, test_source).unwrap();
2552
2553 let extractor = PythonExtractor::new();
2554 let cars_prod = cars_py.to_string_lossy().into_owned();
2555 let helpers_prod = helpers_py.to_string_lossy().into_owned();
2556 let test_path = test_py.to_string_lossy().into_owned();
2557
2558 let production_files = vec![cars_prod.clone(), helpers_prod.clone()];
2559 let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2560 .into_iter()
2561 .collect();
2562
2563 let result = extractor.map_test_files_with_imports(
2565 &production_files,
2566 &test_sources,
2567 tmp.path(),
2568 false,
2569 );
2570
2571 let cars_mapping = result.iter().find(|m| m.production_file == cars_prod);
2573 assert!(
2574 cars_mapping.is_some(),
2575 "models/cars.py not found in mappings: {:?}",
2576 result
2577 );
2578 let cars_m = cars_mapping.unwrap();
2579 assert!(
2580 cars_m.test_files.contains(&test_path),
2581 "test_mixed.py not mapped to models/cars.py via absolute import: {:?}",
2582 cars_m.test_files
2583 );
2584
2585 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
2587 assert!(
2588 helpers_mapping.is_none(),
2589 "tests/helpers.py should be excluded as test helper (Phase 20), but found in mappings: {:?}",
2590 helpers_mapping
2591 );
2592 }
2593
2594 #[test]
2598 fn py_rel_01_bare_two_dot_relative_import() {
2599 let r = run_import_test(
2602 "pkg/utils.py",
2603 "def helper():\n pass\n",
2604 "pkg/sub/test_thing.py",
2605 "from .. import utils\n\ndef test_thing():\n pass\n",
2606 &[],
2607 );
2608
2609 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2611 assert!(
2612 mapping.is_some(),
2613 "pkg/utils.py not found in mappings: {:?}",
2614 r.mappings
2615 );
2616 let mapping = mapping.unwrap();
2617 assert!(
2618 mapping.test_files.contains(&r.test_path),
2619 "test_thing.py not in test_files for pkg/utils.py via bare two-dot import: {:?}",
2620 mapping.test_files
2621 );
2622 }
2623
2624 #[test]
2628 fn py_l2_django_01_tests_file_mapped_via_import_tracing() {
2629 let r = run_import_test(
2632 "src/models.py",
2633 "class Model:\n pass\n",
2634 "app/tests.py",
2635 "from src.models import Model\n\n\ndef test_model():\n pass\n",
2636 &[],
2637 );
2638
2639 let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2641 assert!(
2642 mapping.is_some(),
2643 "src/models.py not found in mappings: {:?}",
2644 r.mappings
2645 );
2646 let mapping = mapping.unwrap();
2647 assert!(
2648 mapping.test_files.contains(&r.test_path),
2649 "app/tests.py not in test_files for src/models.py: {:?}",
2650 mapping.test_files
2651 );
2652 assert_eq!(
2653 mapping.strategy,
2654 MappingStrategy::ImportTracing,
2655 "expected ImportTracing strategy, got {:?}",
2656 mapping.strategy
2657 );
2658 }
2659}
2660
2661const ROUTE_DECORATOR_QUERY: &str = include_str!("../queries/route_decorator.scm");
2666static ROUTE_DECORATOR_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2667
2668const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
2669
2670#[derive(Debug, Clone, PartialEq)]
2672pub struct Route {
2673 pub http_method: String,
2674 pub path: String,
2675 pub handler_name: String,
2676 pub file: String,
2677}
2678
2679fn collect_router_prefixes(
2682 source_bytes: &[u8],
2683 tree: &tree_sitter::Tree,
2684) -> HashMap<String, String> {
2685 let mut prefixes = HashMap::new();
2686
2687 let root = tree.root_node();
2689 let mut stack = vec![root];
2690
2691 while let Some(node) = stack.pop() {
2692 if node.kind() == "assignment" {
2693 let left = node.child_by_field_name("left");
2694 let right = node.child_by_field_name("right");
2695
2696 if let (Some(left_node), Some(right_node)) = (left, right) {
2697 if left_node.kind() == "identifier" && right_node.kind() == "call" {
2698 let var_name = left_node.utf8_text(source_bytes).unwrap_or("").to_string();
2699
2700 let fn_node = right_node.child_by_field_name("function");
2702 let is_api_router = fn_node
2703 .and_then(|f| f.utf8_text(source_bytes).ok())
2704 .map(|name| name == "APIRouter")
2705 .unwrap_or(false);
2706
2707 if is_api_router {
2708 let args_node = right_node.child_by_field_name("arguments");
2710 if let Some(args) = args_node {
2711 let mut args_cursor = args.walk();
2712 for arg in args.named_children(&mut args_cursor) {
2713 if arg.kind() == "keyword_argument" {
2714 let kw_name = arg
2715 .child_by_field_name("name")
2716 .and_then(|n| n.utf8_text(source_bytes).ok())
2717 .unwrap_or("");
2718 if kw_name == "prefix" {
2719 if let Some(val) = arg.child_by_field_name("value") {
2720 if val.kind() == "string" {
2721 let raw = val.utf8_text(source_bytes).unwrap_or("");
2722 let prefix = strip_string_quotes(raw);
2723 prefixes.insert(var_name.clone(), prefix);
2724 }
2725 }
2726 }
2727 }
2728 }
2729 }
2730 prefixes.entry(var_name).or_default();
2732 }
2733 }
2734 }
2735 }
2736
2737 let mut w = node.walk();
2739 let children: Vec<_> = node.named_children(&mut w).collect();
2740 for child in children.into_iter().rev() {
2741 stack.push(child);
2742 }
2743 }
2744
2745 prefixes
2746}
2747
2748fn strip_string_quotes(raw: &str) -> String {
2754 let raw = raw.trim_start_matches(|c: char| "rRbBfFuU".contains(c));
2757 for q in &[r#"""""#, "'''"] {
2759 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2760 return inner.to_string();
2761 }
2762 }
2763 for q in &["\"", "'"] {
2765 if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2766 return inner.to_string();
2767 }
2768 }
2769 raw.to_string()
2770}
2771
2772pub fn extract_routes(source: &str, file_path: &str) -> Vec<Route> {
2774 if source.is_empty() {
2775 return Vec::new();
2776 }
2777
2778 let mut parser = PythonExtractor::parser();
2779 let tree = match parser.parse(source, None) {
2780 Some(t) => t,
2781 None => return Vec::new(),
2782 };
2783 let source_bytes = source.as_bytes();
2784
2785 let router_prefixes = collect_router_prefixes(source_bytes, &tree);
2787
2788 let query = cached_query(&ROUTE_DECORATOR_QUERY_CACHE, ROUTE_DECORATOR_QUERY);
2790
2791 let obj_idx = query.capture_index_for_name("route.object");
2792 let method_idx = query.capture_index_for_name("route.method");
2793 let path_idx = query.capture_index_for_name("route.path");
2794 let handler_idx = query.capture_index_for_name("route.handler");
2795
2796 let mut cursor = QueryCursor::new();
2797 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2798
2799 let mut routes = Vec::new();
2800 let mut seen = HashSet::new();
2801
2802 while let Some(m) = matches.next() {
2803 let mut obj: Option<String> = None;
2804 let mut method: Option<String> = None;
2805 let mut path_raw: Option<String> = None;
2806 let mut path_is_string = false;
2807 let mut handler: Option<String> = None;
2808
2809 for cap in m.captures {
2810 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
2811 if obj_idx == Some(cap.index) {
2812 obj = Some(text);
2813 } else if method_idx == Some(cap.index) {
2814 method = Some(text);
2815 } else if path_idx == Some(cap.index) {
2816 path_is_string = cap.node.kind() == "string";
2818 path_raw = Some(text);
2819 } else if handler_idx == Some(cap.index) {
2820 handler = Some(text);
2821 }
2822 }
2823
2824 let (obj, method, handler) = match (obj, method, handler) {
2825 (Some(o), Some(m), Some(h)) => (o, m, h),
2826 _ => continue,
2827 };
2828
2829 if !HTTP_METHODS.contains(&method.as_str()) {
2831 continue;
2832 }
2833
2834 let sub_path = match path_raw {
2836 Some(ref raw) if path_is_string => strip_string_quotes(raw),
2837 Some(_) => "<dynamic>".to_string(),
2838 None => "<dynamic>".to_string(),
2839 };
2840
2841 let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
2843 let full_path = if prefix.is_empty() {
2844 sub_path
2845 } else {
2846 format!("{prefix}{sub_path}")
2847 };
2848
2849 let key = (method.clone(), full_path.clone(), handler.clone());
2851 if !seen.insert(key) {
2852 continue;
2853 }
2854
2855 routes.push(Route {
2856 http_method: method.to_uppercase(),
2857 path: full_path,
2858 handler_name: handler,
2859 file: file_path.to_string(),
2860 });
2861 }
2862
2863 routes
2864}
2865
2866#[cfg(test)]
2871mod route_tests {
2872 use super::*;
2873
2874 #[test]
2876 fn fa_rt_01_basic_app_get_route() {
2877 let source = r#"
2879from fastapi import FastAPI
2880app = FastAPI()
2881
2882@app.get("/users")
2883def read_users():
2884 return []
2885"#;
2886
2887 let routes = extract_routes(source, "main.py");
2889
2890 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2892 assert_eq!(routes[0].http_method, "GET");
2893 assert_eq!(routes[0].path, "/users");
2894 assert_eq!(routes[0].handler_name, "read_users");
2895 }
2896
2897 #[test]
2899 fn fa_rt_02_multiple_http_methods() {
2900 let source = r#"
2902from fastapi import FastAPI
2903app = FastAPI()
2904
2905@app.get("/items")
2906def list_items():
2907 return []
2908
2909@app.post("/items")
2910def create_item():
2911 return {}
2912
2913@app.put("/items/{item_id}")
2914def update_item(item_id: int):
2915 return {}
2916
2917@app.delete("/items/{item_id}")
2918def delete_item(item_id: int):
2919 return {}
2920"#;
2921
2922 let routes = extract_routes(source, "main.py");
2924
2925 assert_eq!(routes.len(), 4, "expected 4 routes, got {:?}", routes);
2927 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
2928 assert!(methods.contains(&"GET"), "missing GET");
2929 assert!(methods.contains(&"POST"), "missing POST");
2930 assert!(methods.contains(&"PUT"), "missing PUT");
2931 assert!(methods.contains(&"DELETE"), "missing DELETE");
2932 }
2933
2934 #[test]
2936 fn fa_rt_03_path_parameter() {
2937 let source = r#"
2939from fastapi import FastAPI
2940app = FastAPI()
2941
2942@app.get("/items/{item_id}")
2943def read_item(item_id: int):
2944 return {}
2945"#;
2946
2947 let routes = extract_routes(source, "main.py");
2949
2950 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2952 assert_eq!(routes[0].path, "/items/{item_id}");
2953 }
2954
2955 #[test]
2957 fn fa_rt_04_router_get_with_prefix() {
2958 let source = r#"
2960from fastapi import APIRouter
2961
2962router = APIRouter(prefix="/items")
2963
2964@router.get("/{item_id}")
2965def read_item(item_id: int):
2966 return {}
2967"#;
2968
2969 let routes = extract_routes(source, "routes.py");
2971
2972 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2974 assert_eq!(
2975 routes[0].path, "/items/{item_id}",
2976 "expected prefix-resolved path"
2977 );
2978 }
2979
2980 #[test]
2982 fn fa_rt_05_router_get_without_prefix() {
2983 let source = r#"
2985from fastapi import APIRouter
2986
2987router = APIRouter()
2988
2989@router.get("/health")
2990def health_check():
2991 return {"status": "ok"}
2992"#;
2993
2994 let routes = extract_routes(source, "routes.py");
2996
2997 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2999 assert_eq!(routes[0].path, "/health");
3000 }
3001
3002 #[test]
3004 fn fa_rt_06_non_route_decorator_ignored() {
3005 let source = r#"
3007import pytest
3008
3009@pytest.fixture
3010def client():
3011 return None
3012
3013class MyClass:
3014 @staticmethod
3015 def helper():
3016 pass
3017"#;
3018
3019 let routes = extract_routes(source, "main.py");
3021
3022 assert!(
3024 routes.is_empty(),
3025 "expected no routes for non-route decorators, got {:?}",
3026 routes
3027 );
3028 }
3029
3030 #[test]
3032 fn fa_rt_07_dynamic_path_non_literal() {
3033 let source = r#"
3035from fastapi import FastAPI
3036app = FastAPI()
3037
3038ROUTE_PATH = "/dynamic"
3039
3040@app.get(ROUTE_PATH)
3041def dynamic_route():
3042 return {}
3043"#;
3044
3045 let routes = extract_routes(source, "main.py");
3047
3048 assert_eq!(
3050 routes.len(),
3051 1,
3052 "expected 1 route for dynamic path, got {:?}",
3053 routes
3054 );
3055 assert_eq!(
3056 routes[0].path, "<dynamic>",
3057 "expected <dynamic> for non-literal path argument"
3058 );
3059 }
3060
3061 #[test]
3063 fn fa_rt_08_empty_source() {
3064 let source = "";
3066
3067 let routes = extract_routes(source, "main.py");
3069
3070 assert!(routes.is_empty(), "expected empty Vec for empty source");
3072 }
3073
3074 #[test]
3076 fn fa_rt_09_async_def_handler() {
3077 let source = r#"
3079from fastapi import FastAPI
3080app = FastAPI()
3081
3082@app.get("/")
3083async def root():
3084 return {"message": "hello"}
3085"#;
3086
3087 let routes = extract_routes(source, "main.py");
3089
3090 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3092 assert_eq!(
3093 routes[0].handler_name, "root",
3094 "async def should produce handler_name = 'root'"
3095 );
3096 }
3097
3098 #[test]
3100 fn fa_rt_10_multiple_decorators_on_same_function() {
3101 let source = r#"
3103from fastapi import FastAPI
3104app = FastAPI()
3105
3106def require_auth(func):
3107 return func
3108
3109@app.get("/")
3110@require_auth
3111def root():
3112 return {}
3113"#;
3114
3115 let routes = extract_routes(source, "main.py");
3117
3118 assert_eq!(
3120 routes.len(),
3121 1,
3122 "expected exactly 1 route (non-route decorators ignored), got {:?}",
3123 routes
3124 );
3125 assert_eq!(routes[0].http_method, "GET");
3126 assert_eq!(routes[0].path, "/");
3127 assert_eq!(routes[0].handler_name, "root");
3128 }
3129}
3130
3131const DJANGO_URL_PATTERN_QUERY: &str = include_str!("../queries/django_url_pattern.scm");
3136static DJANGO_URL_PATTERN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
3137
3138static DJANGO_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3139static DJANGO_RE_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3140
3141const HTTP_METHOD_ANY: &str = "ANY";
3142
3143pub fn normalize_django_path(path: &str) -> String {
3147 let re = DJANGO_PATH_RE
3148 .get_or_init(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").expect("invalid regex"));
3149 re.replace_all(path, ":$1").into_owned()
3150}
3151
3152pub fn normalize_re_path(path: &str) -> String {
3155 let s = path.strip_prefix('^').unwrap_or(path);
3157 let s = s.strip_suffix('$').unwrap_or(s);
3159 let re = DJANGO_RE_PATH_RE
3165 .get_or_init(|| regex::Regex::new(r"\(\?P<(\w+)>[^)]*\)").expect("invalid regex"));
3166 re.replace_all(s, ":$1").into_owned()
3167}
3168
3169pub fn extract_django_routes(source: &str, file_path: &str) -> Vec<Route> {
3171 if source.is_empty() {
3172 return Vec::new();
3173 }
3174
3175 let mut parser = PythonExtractor::parser();
3176 let tree = match parser.parse(source, None) {
3177 Some(t) => t,
3178 None => return Vec::new(),
3179 };
3180 let source_bytes = source.as_bytes();
3181
3182 let query = cached_query(&DJANGO_URL_PATTERN_QUERY_CACHE, DJANGO_URL_PATTERN_QUERY);
3183
3184 let func_idx = query.capture_index_for_name("django.func");
3185 let path_idx = query.capture_index_for_name("django.path");
3186 let handler_idx = query.capture_index_for_name("django.handler");
3187
3188 let mut cursor = QueryCursor::new();
3189 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
3190
3191 let mut routes = Vec::new();
3192 let mut seen = HashSet::new();
3193
3194 while let Some(m) = matches.next() {
3195 let mut func: Option<String> = None;
3196 let mut path_raw: Option<String> = None;
3197 let mut handler: Option<String> = None;
3198
3199 for cap in m.captures {
3200 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
3201 if func_idx == Some(cap.index) {
3202 func = Some(text);
3203 } else if path_idx == Some(cap.index) {
3204 path_raw = Some(text);
3205 } else if handler_idx == Some(cap.index) {
3206 handler = Some(text);
3207 }
3208 }
3209
3210 let (func, path_raw, handler) = match (func, path_raw, handler) {
3211 (Some(f), Some(p), Some(h)) => (f, p, h),
3212 _ => continue,
3213 };
3214
3215 let raw_path = strip_string_quotes(&path_raw);
3216 let normalized = match func.as_str() {
3217 "re_path" => normalize_re_path(&raw_path),
3218 _ => normalize_django_path(&raw_path),
3219 };
3220
3221 let key = (
3223 HTTP_METHOD_ANY.to_string(),
3224 normalized.clone(),
3225 handler.clone(),
3226 );
3227 if !seen.insert(key) {
3228 continue;
3229 }
3230
3231 routes.push(Route {
3232 http_method: HTTP_METHOD_ANY.to_string(),
3233 path: normalized,
3234 handler_name: handler,
3235 file: file_path.to_string(),
3236 });
3237 }
3238
3239 routes
3240}
3241
3242#[cfg(test)]
3247mod django_route_tests {
3248 use super::*;
3249
3250 #[test]
3256 fn dj_np_01_typed_parameter() {
3257 let result = normalize_django_path("users/<int:pk>/");
3261 assert_eq!(result, "users/:pk/");
3262 }
3263
3264 #[test]
3266 fn dj_np_02_untyped_parameter() {
3267 let result = normalize_django_path("users/<pk>/");
3271 assert_eq!(result, "users/:pk/");
3272 }
3273
3274 #[test]
3276 fn dj_np_03_multiple_parameters() {
3277 let result = normalize_django_path("posts/<slug:slug>/comments/<int:id>/");
3281 assert_eq!(result, "posts/:slug/comments/:id/");
3282 }
3283
3284 #[test]
3286 fn dj_np_04_no_parameters() {
3287 let result = normalize_django_path("users/");
3291 assert_eq!(result, "users/");
3292 }
3293
3294 #[test]
3300 fn dj_nr_01_single_named_group() {
3301 let result = normalize_re_path("^articles/(?P<year>[0-9]{4})/$");
3305 assert_eq!(result, "articles/:year/");
3306 }
3307
3308 #[test]
3310 fn dj_nr_02_multiple_named_groups() {
3311 let result = normalize_re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$");
3315 assert_eq!(result, ":year/:month/");
3316 }
3317
3318 #[test]
3320 fn dj_nr_03_no_named_groups() {
3321 let result = normalize_re_path("^users/$");
3325 assert_eq!(result, "users/");
3326 }
3327
3328 #[test]
3330 fn dj_nr_04_character_class_caret_preserved() {
3331 let result = normalize_re_path("^items/[^/]+/$");
3335 assert_eq!(result, "items/[^/]+/");
3336 }
3337
3338 #[test]
3344 fn dj_rt_01_basic_path_attribute_handler() {
3345 let source = r#"
3347from django.urls import path
3348from . import views
3349
3350urlpatterns = [
3351 path("users/", views.user_list),
3352]
3353"#;
3354 let routes = extract_django_routes(source, "urls.py");
3356
3357 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3359 assert_eq!(routes[0].http_method, "ANY");
3360 assert_eq!(routes[0].path, "users/");
3361 assert_eq!(routes[0].handler_name, "user_list");
3362 }
3363
3364 #[test]
3366 fn dj_rt_02_path_direct_import_handler() {
3367 let source = r#"
3369from django.urls import path
3370from .views import user_list
3371
3372urlpatterns = [
3373 path("users/", user_list),
3374]
3375"#;
3376 let routes = extract_django_routes(source, "urls.py");
3378
3379 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3381 assert_eq!(routes[0].http_method, "ANY");
3382 assert_eq!(routes[0].path, "users/");
3383 assert_eq!(routes[0].handler_name, "user_list");
3384 }
3385
3386 #[test]
3388 fn dj_rt_03_path_typed_parameter() {
3389 let source = r#"
3391from django.urls import path
3392from . import views
3393
3394urlpatterns = [
3395 path("users/<int:pk>/", views.user_detail),
3396]
3397"#;
3398 let routes = extract_django_routes(source, "urls.py");
3400
3401 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3403 assert_eq!(routes[0].path, "users/:pk/");
3404 }
3405
3406 #[test]
3408 fn dj_rt_04_path_untyped_parameter() {
3409 let source = r#"
3411from django.urls import path
3412from . import views
3413
3414urlpatterns = [
3415 path("users/<pk>/", views.user_detail),
3416]
3417"#;
3418 let routes = extract_django_routes(source, "urls.py");
3420
3421 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3423 assert_eq!(routes[0].path, "users/:pk/");
3424 }
3425
3426 #[test]
3428 fn dj_rt_05_re_path_named_group() {
3429 let source = r#"
3431from django.urls import re_path
3432from . import views
3433
3434urlpatterns = [
3435 re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive),
3436]
3437"#;
3438 let routes = extract_django_routes(source, "urls.py");
3440
3441 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3443 assert_eq!(routes[0].path, "articles/:year/");
3444 }
3445
3446 #[test]
3448 fn dj_rt_06_multiple_routes() {
3449 let source = r#"
3451from django.urls import path
3452from . import views
3453
3454urlpatterns = [
3455 path("users/", views.user_list),
3456 path("users/<int:pk>/", views.user_detail),
3457 path("about/", views.about),
3458]
3459"#;
3460 let routes = extract_django_routes(source, "urls.py");
3462
3463 assert_eq!(routes.len(), 3, "expected 3 routes, got {:?}", routes);
3465 for r in &routes {
3466 assert_eq!(r.http_method, "ANY", "expected method ANY for {:?}", r);
3467 }
3468 }
3469
3470 #[test]
3472 fn dj_rt_07_path_with_name_kwarg() {
3473 let source = r#"
3475from django.urls import path
3476from . import views
3477
3478urlpatterns = [
3479 path("login/", views.login_view, name="login"),
3480]
3481"#;
3482 let routes = extract_django_routes(source, "urls.py");
3484
3485 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3487 assert_eq!(routes[0].handler_name, "login_view");
3488 }
3489
3490 #[test]
3492 fn dj_rt_08_empty_source() {
3493 let routes = extract_django_routes("", "urls.py");
3496
3497 assert!(routes.is_empty(), "expected empty Vec for empty source");
3499 }
3500
3501 #[test]
3503 fn dj_rt_09_no_path_calls() {
3504 let source = r#"
3506from django.db import models
3507
3508class User(models.Model):
3509 name = models.CharField(max_length=100)
3510"#;
3511 let routes = extract_django_routes(source, "models.py");
3513
3514 assert!(
3516 routes.is_empty(),
3517 "expected empty Vec for non-URL source, got {:?}",
3518 routes
3519 );
3520 }
3521
3522 #[test]
3524 fn dj_rt_10_deduplication() {
3525 let source = r#"
3527from django.urls import path
3528from . import views
3529
3530urlpatterns = [
3531 path("users/", views.user_list),
3532 path("users/", views.user_list),
3533]
3534"#;
3535 let routes = extract_django_routes(source, "urls.py");
3537
3538 assert_eq!(
3540 routes.len(),
3541 1,
3542 "expected 1 route after dedup, got {:?}",
3543 routes
3544 );
3545 }
3546
3547 #[test]
3549 fn dj_rt_11_include_is_ignored() {
3550 let source = r#"
3552from django.urls import path, include
3553
3554urlpatterns = [
3555 path("api/", include("myapp.urls")),
3556]
3557"#;
3558 let routes = extract_django_routes(source, "urls.py");
3560
3561 assert!(
3563 routes.is_empty(),
3564 "expected empty Vec for include()-only urlpatterns, got {:?}",
3565 routes
3566 );
3567 }
3568
3569 #[test]
3571 fn dj_rt_12_multiple_path_parameters() {
3572 let source = r#"
3574from django.urls import path
3575from . import views
3576
3577urlpatterns = [
3578 path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail),
3579]
3580"#;
3581 let routes = extract_django_routes(source, "urls.py");
3583
3584 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3586 assert_eq!(routes[0].path, "posts/:slug/comments/:id/");
3587 }
3588
3589 #[test]
3591 fn dj_rt_13_re_path_multiple_named_groups() {
3592 let source = r#"
3594from django.urls import re_path
3595from . import views
3596
3597urlpatterns = [
3598 re_path(r"^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive),
3599]
3600"#;
3601 let routes = extract_django_routes(source, "urls.py");
3603
3604 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3606 assert_eq!(routes[0].path, ":year/:month/");
3607 }
3608
3609 #[test]
3615 fn dj_rt_e2e_01_observe_django_routes_coverage() {
3616 use tempfile::TempDir;
3617
3618 let dir = TempDir::new().unwrap();
3620 let urls_py = dir.path().join("urls.py");
3621 let test_urls_py = dir.path().join("test_urls.py");
3622
3623 std::fs::write(
3624 &urls_py,
3625 r#"from django.urls import path
3626from . import views
3627
3628urlpatterns = [
3629 path("users/", views.user_list),
3630 path("users/<int:pk>/", views.user_detail),
3631]
3632"#,
3633 )
3634 .unwrap();
3635
3636 std::fs::write(
3637 &test_urls_py,
3638 r#"def test_user_list():
3639 pass
3640
3641def test_user_detail():
3642 pass
3643"#,
3644 )
3645 .unwrap();
3646
3647 let urls_source = std::fs::read_to_string(&urls_py).unwrap();
3649 let urls_path = urls_py.to_string_lossy().into_owned();
3650
3651 let routes = extract_django_routes(&urls_source, &urls_path);
3652
3653 assert_eq!(
3655 routes.len(),
3656 2,
3657 "expected 2 routes extracted from urls.py, got {:?}",
3658 routes
3659 );
3660
3661 for r in &routes {
3663 assert_eq!(r.http_method, "ANY", "expected method ANY, got {:?}", r);
3664 }
3665 }
3666
3667 #[test]
3672 fn py_import_04_e2e_bare_import_wildcard_barrel_mapped() {
3673 use tempfile::TempDir;
3674
3675 let dir = TempDir::new().unwrap();
3678 let pkg = dir.path().join("pkg");
3679 std::fs::create_dir_all(&pkg).unwrap();
3680
3681 std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
3682 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
3683
3684 let tests_dir = dir.path().join("tests");
3685 std::fs::create_dir_all(&tests_dir).unwrap();
3686 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
3687 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3688
3689 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3690 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3691
3692 let extractor = PythonExtractor::new();
3693 let production_files = vec![module_path.clone()];
3694 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3695 .into_iter()
3696 .collect();
3697
3698 let result = extractor.map_test_files_with_imports(
3700 &production_files,
3701 &test_sources,
3702 dir.path(),
3703 false,
3704 );
3705
3706 let mapping = result.iter().find(|m| m.production_file == module_path);
3708 assert!(
3709 mapping.is_some(),
3710 "module.py not mapped; bare import + wildcard barrel should resolve. mappings={:?}",
3711 result
3712 );
3713 let mapping = mapping.unwrap();
3714 assert!(
3715 mapping.test_files.contains(&test_path),
3716 "test_foo.py not in test_files for module.py: {:?}",
3717 mapping.test_files
3718 );
3719 }
3720
3721 #[test]
3726 fn py_import_05_e2e_bare_import_named_barrel_mapped() {
3727 use tempfile::TempDir;
3728
3729 let dir = TempDir::new().unwrap();
3732 let pkg = dir.path().join("pkg");
3733 std::fs::create_dir_all(&pkg).unwrap();
3734
3735 std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
3736 std::fs::write(pkg.join("module.py"), "class Foo:\n pass\n").unwrap();
3737
3738 let tests_dir = dir.path().join("tests");
3739 std::fs::create_dir_all(&tests_dir).unwrap();
3740 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Foo()\n";
3741 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3742
3743 let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3744 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3745
3746 let extractor = PythonExtractor::new();
3747 let production_files = vec![module_path.clone()];
3748 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3749 .into_iter()
3750 .collect();
3751
3752 let result = extractor.map_test_files_with_imports(
3754 &production_files,
3755 &test_sources,
3756 dir.path(),
3757 false,
3758 );
3759
3760 let mapping = result.iter().find(|m| m.production_file == module_path);
3762 assert!(
3763 mapping.is_some(),
3764 "module.py not mapped; bare import + named barrel should resolve. mappings={:?}",
3765 result
3766 );
3767 let mapping = mapping.unwrap();
3768 assert!(
3769 mapping.test_files.contains(&test_path),
3770 "test_foo.py not in test_files for module.py: {:?}",
3771 mapping.test_files
3772 );
3773 }
3774
3775 #[test]
3780 fn py_attr_01_bare_import_single_attribute() {
3781 let source = "import httpx\nhttpx.Client()\n";
3783
3784 let extractor = PythonExtractor::new();
3786 let result = extractor.extract_all_import_specifiers(source);
3787
3788 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3790 assert!(entry.is_some(), "httpx not found in {:?}", result);
3791 let (_, symbols) = entry.unwrap();
3792 assert_eq!(
3793 symbols,
3794 &vec!["Client".to_string()],
3795 "expected [\"Client\"] for bare import with attribute access, got {:?}",
3796 symbols
3797 );
3798 }
3799
3800 #[test]
3805 fn py_attr_02_bare_import_multiple_attributes() {
3806 let source = "import httpx\nhttpx.Client()\nhttpx.get()\n";
3808
3809 let extractor = PythonExtractor::new();
3811 let result = extractor.extract_all_import_specifiers(source);
3812
3813 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3815 assert!(entry.is_some(), "httpx not found in {:?}", result);
3816 let (_, symbols) = entry.unwrap();
3817 assert!(
3818 symbols.contains(&"Client".to_string()),
3819 "Client not in symbols: {:?}",
3820 symbols
3821 );
3822 assert!(
3823 symbols.contains(&"get".to_string()),
3824 "get not in symbols: {:?}",
3825 symbols
3826 );
3827 }
3828
3829 #[test]
3834 fn py_attr_03_bare_import_deduplicated_attributes() {
3835 let source = "import httpx\nhttpx.Client()\nhttpx.Client()\n";
3837
3838 let extractor = PythonExtractor::new();
3840 let result = extractor.extract_all_import_specifiers(source);
3841
3842 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3844 assert!(entry.is_some(), "httpx not found in {:?}", result);
3845 let (_, symbols) = entry.unwrap();
3846 assert_eq!(
3847 symbols,
3848 &vec!["Client".to_string()],
3849 "expected [\"Client\"] with deduplication, got {:?}",
3850 symbols
3851 );
3852 }
3853
3854 #[test]
3864 fn py_attr_04_bare_import_no_attribute_fallback() {
3865 let source = "import httpx\n";
3867
3868 let extractor = PythonExtractor::new();
3870 let result = extractor.extract_all_import_specifiers(source);
3871
3872 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3874 assert!(
3875 entry.is_some(),
3876 "httpx not found in {:?}; bare import without attribute access should be included",
3877 result
3878 );
3879 let (_, symbols) = entry.unwrap();
3880 assert!(
3881 symbols.is_empty(),
3882 "expected empty symbols (fallback) for bare import with no attribute access, got {:?}",
3883 symbols
3884 );
3885 }
3886
3887 #[test]
3898 fn py_attr_05_from_import_regression() {
3899 let source = "from httpx import Client\n";
3901
3902 let extractor = PythonExtractor::new();
3904 let result = extractor.extract_all_import_specifiers(source);
3905
3906 let entry = result.iter().find(|(spec, _)| spec == "httpx");
3908 assert!(entry.is_some(), "httpx not found in {:?}", result);
3909 let (_, symbols) = entry.unwrap();
3910 assert!(
3911 symbols.contains(&"Client".to_string()),
3912 "Client not in symbols: {:?}",
3913 symbols
3914 );
3915 }
3916
3917 #[test]
3923 fn py_attr_06_e2e_attribute_access_narrows_barrel_mapping() {
3924 use tempfile::TempDir;
3925
3926 let dir = TempDir::new().unwrap();
3932 let pkg = dir.path().join("pkg");
3933 std::fs::create_dir_all(&pkg).unwrap();
3934
3935 std::fs::write(
3936 pkg.join("__init__.py"),
3937 "from .mod import Foo\nfrom .bar import Bar\n",
3938 )
3939 .unwrap();
3940 std::fs::write(pkg.join("mod.py"), "def Foo(): pass\n").unwrap();
3941 std::fs::write(pkg.join("bar.py"), "def Bar(): pass\n").unwrap();
3942
3943 let tests_dir = dir.path().join("tests");
3944 std::fs::create_dir_all(&tests_dir).unwrap();
3945 let test_content = "import pkg\npkg.Foo()\n";
3947 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3948
3949 let mod_path = pkg.join("mod.py").to_string_lossy().into_owned();
3950 let bar_path = pkg.join("bar.py").to_string_lossy().into_owned();
3951 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3952
3953 let extractor = PythonExtractor::new();
3954 let production_files = vec![mod_path.clone(), bar_path.clone()];
3955 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3956 .into_iter()
3957 .collect();
3958
3959 let result = extractor.map_test_files_with_imports(
3961 &production_files,
3962 &test_sources,
3963 dir.path(),
3964 false,
3965 );
3966
3967 let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
3969 assert!(
3970 mod_mapping.is_some(),
3971 "mod.py not mapped; pkg.Foo() should resolve to mod.py via barrel. mappings={:?}",
3972 result
3973 );
3974 assert!(
3975 mod_mapping.unwrap().test_files.contains(&test_path),
3976 "test_foo.py not in test_files for mod.py: {:?}",
3977 mod_mapping.unwrap().test_files
3978 );
3979
3980 let bar_mapping = result.iter().find(|m| m.production_file == bar_path);
3982 let bar_not_mapped = bar_mapping
3983 .map(|m| !m.test_files.contains(&test_path))
3984 .unwrap_or(true);
3985 assert!(
3986 bar_not_mapped,
3987 "bar.py should NOT be mapped for test_foo.py (pkg.Bar() is not accessed), but got: {:?}",
3988 bar_mapping
3989 );
3990 }
3991
3992 #[test]
4002 fn py_l1x_01_stem_only_fallback_cross_directory() {
4003 use tempfile::TempDir;
4004
4005 let dir = TempDir::new().unwrap();
4010 let pkg = dir.path().join("pkg");
4011 std::fs::create_dir_all(&pkg).unwrap();
4012 let tests_dir = dir.path().join("tests");
4013 std::fs::create_dir_all(&tests_dir).unwrap();
4014
4015 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4016
4017 let test_content = "def test_client():\n pass\n";
4019 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4020
4021 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4022 let test_path = tests_dir
4023 .join("test_client.py")
4024 .to_string_lossy()
4025 .into_owned();
4026
4027 let extractor = PythonExtractor::new();
4028 let production_files = vec![client_path.clone()];
4029 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4030 .into_iter()
4031 .collect();
4032
4033 let result = extractor.map_test_files_with_imports(
4035 &production_files,
4036 &test_sources,
4037 dir.path(),
4038 false,
4039 );
4040
4041 let mapping = result.iter().find(|m| m.production_file == client_path);
4043 assert!(
4044 mapping.is_some(),
4045 "pkg/_client.py not mapped; stem-only fallback should match across directories. mappings={:?}",
4046 result
4047 );
4048 let mapping = mapping.unwrap();
4049 assert!(
4050 mapping.test_files.contains(&test_path),
4051 "test_client.py not in test_files for pkg/_client.py: {:?}",
4052 mapping.test_files
4053 );
4054 }
4055
4056 #[test]
4064 fn py_l1x_02_stem_only_underscore_prefix_prod() {
4065 use tempfile::TempDir;
4066
4067 let dir = TempDir::new().unwrap();
4069 let pkg = dir.path().join("pkg");
4070 std::fs::create_dir_all(&pkg).unwrap();
4071 let tests_dir = dir.path().join("tests");
4072 std::fs::create_dir_all(&tests_dir).unwrap();
4073
4074 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4075
4076 let test_content = "def test_decode():\n pass\n";
4078 std::fs::write(tests_dir.join("test_decoders.py"), test_content).unwrap();
4079
4080 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4081 let test_path = tests_dir
4082 .join("test_decoders.py")
4083 .to_string_lossy()
4084 .into_owned();
4085
4086 let extractor = PythonExtractor::new();
4087 let production_files = vec![decoders_path.clone()];
4088 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4089 .into_iter()
4090 .collect();
4091
4092 let result = extractor.map_test_files_with_imports(
4094 &production_files,
4095 &test_sources,
4096 dir.path(),
4097 false,
4098 );
4099
4100 let mapping = result.iter().find(|m| m.production_file == decoders_path);
4103 assert!(
4104 mapping.is_some(),
4105 "pkg/_decoders.py not mapped; stem-only fallback should strip _ prefix and match. mappings={:?}",
4106 result
4107 );
4108 let mapping = mapping.unwrap();
4109 assert!(
4110 mapping.test_files.contains(&test_path),
4111 "test_decoders.py not in test_files for pkg/_decoders.py: {:?}",
4112 mapping.test_files
4113 );
4114 }
4115
4116 #[test]
4123 fn py_l1x_03_stem_only_subdirectory_prod() {
4124 use tempfile::TempDir;
4125
4126 let dir = TempDir::new().unwrap();
4128 let transports = dir.path().join("pkg").join("transports");
4129 std::fs::create_dir_all(&transports).unwrap();
4130 let tests_dir = dir.path().join("tests");
4131 std::fs::create_dir_all(&tests_dir).unwrap();
4132
4133 std::fs::write(
4134 transports.join("asgi.py"),
4135 "class ASGITransport:\n pass\n",
4136 )
4137 .unwrap();
4138
4139 let test_content = "def test_asgi_transport():\n pass\n";
4141 std::fs::write(tests_dir.join("test_asgi.py"), test_content).unwrap();
4142
4143 let asgi_path = transports.join("asgi.py").to_string_lossy().into_owned();
4144 let test_path = tests_dir
4145 .join("test_asgi.py")
4146 .to_string_lossy()
4147 .into_owned();
4148
4149 let extractor = PythonExtractor::new();
4150 let production_files = vec![asgi_path.clone()];
4151 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4152 .into_iter()
4153 .collect();
4154
4155 let result = extractor.map_test_files_with_imports(
4157 &production_files,
4158 &test_sources,
4159 dir.path(),
4160 false,
4161 );
4162
4163 let mapping = result.iter().find(|m| m.production_file == asgi_path);
4166 assert!(
4167 mapping.is_some(),
4168 "pkg/transports/asgi.py not mapped; stem 'asgi' should match across directory depth. mappings={:?}",
4169 result
4170 );
4171 let mapping = mapping.unwrap();
4172 assert!(
4173 mapping.test_files.contains(&test_path),
4174 "test_asgi.py not in test_files for pkg/transports/asgi.py: {:?}",
4175 mapping.test_files
4176 );
4177 }
4178
4179 #[test]
4187 fn py_l1x_04_stem_collision_defers_to_l2() {
4188 use tempfile::TempDir;
4189
4190 let dir = TempDir::new().unwrap();
4193 let pkg = dir.path().join("pkg");
4194 let pkg_aio = pkg.join("aio");
4195 std::fs::create_dir_all(&pkg).unwrap();
4196 std::fs::create_dir_all(&pkg_aio).unwrap();
4197 let tests_dir = dir.path().join("tests");
4198 std::fs::create_dir_all(&tests_dir).unwrap();
4199
4200 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4201 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4202
4203 let test_content = "def test_client():\n pass\n";
4205 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4206
4207 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4208 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4209 let test_path = tests_dir
4210 .join("test_client.py")
4211 .to_string_lossy()
4212 .into_owned();
4213
4214 let extractor = PythonExtractor::new();
4215 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4216 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4217 .into_iter()
4218 .collect();
4219
4220 let result = extractor.map_test_files_with_imports(
4222 &production_files,
4223 &test_sources,
4224 dir.path(),
4225 false,
4226 );
4227
4228 let client_mapped = result
4230 .iter()
4231 .find(|m| m.production_file == client_path)
4232 .map(|m| m.test_files.contains(&test_path))
4233 .unwrap_or(false);
4234 assert!(
4235 !client_mapped,
4236 "test_client.py should NOT be mapped to pkg/client.py (stem collision -> defer to L2). mappings={:?}",
4237 result
4238 );
4239
4240 let aio_mapped = result
4241 .iter()
4242 .find(|m| m.production_file == aio_client_path)
4243 .map(|m| m.test_files.contains(&test_path))
4244 .unwrap_or(false);
4245 assert!(
4246 !aio_mapped,
4247 "test_client.py should NOT be mapped to pkg/aio/client.py (stem collision -> defer to L2). mappings={:?}",
4248 result
4249 );
4250 }
4251
4252 #[test]
4260 fn py_l1x_05_l1_core_match_suppresses_fallback() {
4261 use tempfile::TempDir;
4262
4263 let dir = TempDir::new().unwrap();
4267 let pkg = dir.path().join("pkg");
4268 let svc = dir.path().join("svc");
4269 std::fs::create_dir_all(&pkg).unwrap();
4270 std::fs::create_dir_all(&svc).unwrap();
4271
4272 std::fs::write(svc.join("client.py"), "class Client:\n pass\n").unwrap();
4273 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4274
4275 let test_content = "def test_client():\n pass\n";
4277 std::fs::write(svc.join("test_client.py"), test_content).unwrap();
4278
4279 let svc_client_path = svc.join("client.py").to_string_lossy().into_owned();
4280 let pkg_client_path = pkg.join("client.py").to_string_lossy().into_owned();
4281 let test_path = svc.join("test_client.py").to_string_lossy().into_owned();
4282
4283 let extractor = PythonExtractor::new();
4284 let production_files = vec![svc_client_path.clone(), pkg_client_path.clone()];
4285 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4286 .into_iter()
4287 .collect();
4288
4289 let result = extractor.map_test_files_with_imports(
4291 &production_files,
4292 &test_sources,
4293 dir.path(),
4294 false,
4295 );
4296
4297 let svc_client_mapped = result
4299 .iter()
4300 .find(|m| m.production_file == svc_client_path)
4301 .map(|m| m.test_files.contains(&test_path))
4302 .unwrap_or(false);
4303 assert!(
4304 svc_client_mapped,
4305 "test_client.py should be mapped to svc/client.py via L1 core. mappings={:?}",
4306 result
4307 );
4308
4309 let pkg_not_mapped = result
4311 .iter()
4312 .find(|m| m.production_file == pkg_client_path)
4313 .map(|m| !m.test_files.contains(&test_path))
4314 .unwrap_or(true);
4315 assert!(
4316 pkg_not_mapped,
4317 "pkg/client.py should NOT be mapped (L1 core match suppresses stem-only fallback). mappings={:?}",
4318 result
4319 );
4320 }
4321
4322 #[test]
4329 fn py_l1x_06_stem_collision_with_l2_import_resolves_correctly() {
4330 use std::collections::HashMap;
4331 use tempfile::TempDir;
4332
4333 let dir = TempDir::new().unwrap();
4336 let pkg = dir.path().join("pkg");
4337 let pkg_aio = pkg.join("aio");
4338 std::fs::create_dir_all(&pkg).unwrap();
4339 std::fs::create_dir_all(&pkg_aio).unwrap();
4340 let tests_dir = dir.path().join("tests");
4341 std::fs::create_dir_all(&tests_dir).unwrap();
4342
4343 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4344 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4345
4346 let test_content =
4348 "from pkg.client import Client\n\ndef test_client():\n assert Client()\n";
4349 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4350
4351 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4352 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4353 let test_path = tests_dir
4354 .join("test_client.py")
4355 .to_string_lossy()
4356 .into_owned();
4357
4358 let extractor = PythonExtractor::new();
4359 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4360 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4361 .into_iter()
4362 .collect();
4363
4364 let result = extractor.map_test_files_with_imports(
4366 &production_files,
4367 &test_sources,
4368 dir.path(),
4369 false,
4370 );
4371
4372 let client_mapping = result.iter().find(|m| m.production_file == client_path);
4374 assert!(
4375 client_mapping.is_some(),
4376 "pkg/client.py not found in mappings: {:?}",
4377 result
4378 );
4379 let client_mapping = client_mapping.unwrap();
4380 assert!(
4381 client_mapping.test_files.contains(&test_path),
4382 "test_client.py should be mapped to pkg/client.py via L2. mappings={:?}",
4383 result
4384 );
4385 assert_eq!(
4386 client_mapping.strategy,
4387 MappingStrategy::ImportTracing,
4388 "strategy should be ImportTracing (L2), got {:?}",
4389 client_mapping.strategy
4390 );
4391
4392 let aio_mapped = result
4394 .iter()
4395 .find(|m| m.production_file == aio_client_path)
4396 .map(|m| m.test_files.contains(&test_path))
4397 .unwrap_or(false);
4398 assert!(
4399 !aio_mapped,
4400 "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4401 result
4402 );
4403 }
4404
4405 #[test]
4412 fn py_l1x_07_stem_collision_with_barrel_import_resolves_correctly() {
4413 use std::collections::HashMap;
4414 use tempfile::TempDir;
4415
4416 let dir = TempDir::new().unwrap();
4420 let pkg = dir.path().join("pkg");
4421 let pkg_aio = pkg.join("aio");
4422 std::fs::create_dir_all(&pkg).unwrap();
4423 std::fs::create_dir_all(&pkg_aio).unwrap();
4424 let tests_dir = dir.path().join("tests");
4425 std::fs::create_dir_all(&tests_dir).unwrap();
4426
4427 std::fs::write(pkg.join("__init__.py"), "from .client import Client\n").unwrap();
4429 std::fs::write(pkg.join("client.py"), "class Client:\n pass\n").unwrap();
4430 std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n pass\n").unwrap();
4431
4432 let test_content = "from pkg import Client\n\ndef test_client():\n assert Client()\n";
4434 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4435
4436 let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4437 let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4438 let test_path = tests_dir
4439 .join("test_client.py")
4440 .to_string_lossy()
4441 .into_owned();
4442
4443 let extractor = PythonExtractor::new();
4444 let production_files = vec![client_path.clone(), aio_client_path.clone()];
4445 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4446 .into_iter()
4447 .collect();
4448
4449 let result = extractor.map_test_files_with_imports(
4451 &production_files,
4452 &test_sources,
4453 dir.path(),
4454 false,
4455 );
4456
4457 let client_mapped = result
4460 .iter()
4461 .find(|m| m.production_file == client_path)
4462 .map(|m| m.test_files.contains(&test_path))
4463 .unwrap_or(false);
4464 assert!(
4465 client_mapped,
4466 "test_client.py should be mapped to pkg/client.py via barrel L2. mappings={:?}",
4467 result
4468 );
4469
4470 let aio_mapped = result
4472 .iter()
4473 .find(|m| m.production_file == aio_client_path)
4474 .map(|m| m.test_files.contains(&test_path))
4475 .unwrap_or(false);
4476 assert!(
4477 !aio_mapped,
4478 "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4479 result
4480 );
4481 }
4482
4483 #[test]
4493 fn py_sup_01_barrel_suppression_l1_matched_no_barrel_fan_out() {
4494 use tempfile::TempDir;
4495
4496 let dir = TempDir::new().unwrap();
4500 let pkg = dir.path().join("pkg");
4501 std::fs::create_dir_all(&pkg).unwrap();
4502 let tests_dir = dir.path().join("tests");
4503 std::fs::create_dir_all(&tests_dir).unwrap();
4504
4505 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4506 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4507 std::fs::write(
4508 pkg.join("__init__.py"),
4509 "from ._client import Client\nfrom ._utils import format_url\n",
4510 )
4511 .unwrap();
4512
4513 let test_content = "import pkg\n\ndef test_client():\n pass\n";
4517 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4518
4519 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4520 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4521 let test_path = tests_dir
4522 .join("test_client.py")
4523 .to_string_lossy()
4524 .into_owned();
4525
4526 let extractor = PythonExtractor::new();
4527 let production_files = vec![client_path.clone(), utils_path.clone()];
4528 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4529 .into_iter()
4530 .collect();
4531
4532 let result = extractor.map_test_files_with_imports(
4534 &production_files,
4535 &test_sources,
4536 dir.path(),
4537 false,
4538 );
4539
4540 let client_mapped = result
4542 .iter()
4543 .find(|m| m.production_file == client_path)
4544 .map(|m| m.test_files.contains(&test_path))
4545 .unwrap_or(false);
4546 assert!(
4547 client_mapped,
4548 "pkg/_client.py should be mapped via L1 stem-only. mappings={:?}",
4549 result
4550 );
4551
4552 let utils_not_mapped = result
4554 .iter()
4555 .find(|m| m.production_file == utils_path)
4556 .map(|m| !m.test_files.contains(&test_path))
4557 .unwrap_or(true);
4558 assert!(
4559 utils_not_mapped,
4560 "pkg/_utils.py should NOT be mapped (barrel suppression for L1-matched test_client.py). mappings={:?}",
4561 result
4562 );
4563 }
4564
4565 #[test]
4572 fn py_sup_02_barrel_suppression_direct_import_still_added() {
4573 use tempfile::TempDir;
4574
4575 let dir = TempDir::new().unwrap();
4581 let pkg = dir.path().join("pkg");
4582 std::fs::create_dir_all(&pkg).unwrap();
4583 let tests_dir = dir.path().join("tests");
4584 std::fs::create_dir_all(&tests_dir).unwrap();
4585
4586 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4587 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4588 std::fs::write(
4589 pkg.join("__init__.py"),
4590 "from ._client import Client\nfrom ._utils import format_url\n",
4591 )
4592 .unwrap();
4593
4594 let test_content =
4596 "import pkg\nfrom pkg._utils import format_url\n\ndef test_client():\n assert format_url('http://x')\n";
4597 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4598
4599 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4600 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4601 let test_path = tests_dir
4602 .join("test_client.py")
4603 .to_string_lossy()
4604 .into_owned();
4605
4606 let extractor = PythonExtractor::new();
4607 let production_files = vec![client_path.clone(), utils_path.clone()];
4608 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4609 .into_iter()
4610 .collect();
4611
4612 let result = extractor.map_test_files_with_imports(
4614 &production_files,
4615 &test_sources,
4616 dir.path(),
4617 false,
4618 );
4619
4620 let utils_mapped = result
4622 .iter()
4623 .find(|m| m.production_file == utils_path)
4624 .map(|m| m.test_files.contains(&test_path))
4625 .unwrap_or(false);
4626 assert!(
4627 utils_mapped,
4628 "pkg/_utils.py should be mapped via direct import (not barrel). mappings={:?}",
4629 result
4630 );
4631 }
4632
4633 #[test]
4640 fn py_sup_03_barrel_suppression_l1_unmatched_gets_barrel() {
4641 use tempfile::TempDir;
4642
4643 let dir = TempDir::new().unwrap();
4647 let pkg = dir.path().join("pkg");
4648 std::fs::create_dir_all(&pkg).unwrap();
4649 let tests_dir = dir.path().join("tests");
4650 std::fs::create_dir_all(&tests_dir).unwrap();
4651
4652 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4653 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4654 std::fs::write(
4655 pkg.join("__init__.py"),
4656 "from ._client import Client\nfrom ._utils import format_url\n",
4657 )
4658 .unwrap();
4659
4660 let test_content = "import pkg\n\ndef test_exported_members():\n pass\n";
4662 std::fs::write(tests_dir.join("test_exported_members.py"), test_content).unwrap();
4663
4664 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4665 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4666 let test_path = tests_dir
4667 .join("test_exported_members.py")
4668 .to_string_lossy()
4669 .into_owned();
4670
4671 let extractor = PythonExtractor::new();
4672 let production_files = vec![client_path.clone(), utils_path.clone()];
4673 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4674 .into_iter()
4675 .collect();
4676
4677 let result = extractor.map_test_files_with_imports(
4679 &production_files,
4680 &test_sources,
4681 dir.path(),
4682 false,
4683 );
4684
4685 let client_mapped = result
4688 .iter()
4689 .find(|m| m.production_file == client_path)
4690 .map(|m| m.test_files.contains(&test_path))
4691 .unwrap_or(false);
4692 let utils_mapped = result
4693 .iter()
4694 .find(|m| m.production_file == utils_path)
4695 .map(|m| m.test_files.contains(&test_path))
4696 .unwrap_or(false);
4697
4698 assert!(
4699 client_mapped && utils_mapped,
4700 "L1-unmatched test should fan-out via barrel to both _client.py and _utils.py. client_mapped={}, utils_mapped={}, mappings={:?}",
4701 client_mapped,
4702 utils_mapped,
4703 result
4704 );
4705 }
4706
4707 #[test]
4719 fn py_sup_04_e2e_httpx_like_precision_improvement() {
4720 use tempfile::TempDir;
4721 use HashSet;
4722
4723 let dir = TempDir::new().unwrap();
4731 let pkg = dir.path().join("pkg");
4732 std::fs::create_dir_all(&pkg).unwrap();
4733 let tests_dir = dir.path().join("tests");
4734 std::fs::create_dir_all(&tests_dir).unwrap();
4735
4736 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
4737 std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4738 std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4739 std::fs::write(
4740 pkg.join("__init__.py"),
4741 "from ._client import Client\nfrom ._decoders import decode\nfrom ._utils import format_url\n",
4742 )
4743 .unwrap();
4744
4745 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4746 let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4747 let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4748 let production_files = vec![
4749 client_path.clone(),
4750 decoders_path.clone(),
4751 utils_path.clone(),
4752 ];
4753
4754 let test_client_content = "import pkg\n\ndef test_client():\n pass\n";
4758 let test_decoders_content = "import pkg\n\ndef test_decode():\n pass\n";
4759 let test_utils_content = "import pkg\n\ndef test_format_url():\n pass\n";
4760 let test_exported_content = "import pkg\n\ndef test_exported_members():\n pass\n";
4761
4762 let test_client_path = tests_dir
4763 .join("test_client.py")
4764 .to_string_lossy()
4765 .into_owned();
4766 let test_decoders_path = tests_dir
4767 .join("test_decoders.py")
4768 .to_string_lossy()
4769 .into_owned();
4770 let test_utils_path = tests_dir
4771 .join("test_utils.py")
4772 .to_string_lossy()
4773 .into_owned();
4774 let test_exported_path = tests_dir
4775 .join("test_exported_members.py")
4776 .to_string_lossy()
4777 .into_owned();
4778
4779 std::fs::write(&test_client_path, test_client_content).unwrap();
4780 std::fs::write(&test_decoders_path, test_decoders_content).unwrap();
4781 std::fs::write(&test_utils_path, test_utils_content).unwrap();
4782 std::fs::write(&test_exported_path, test_exported_content).unwrap();
4783
4784 let test_sources: HashMap<String, String> = [
4785 (test_client_path.clone(), test_client_content.to_string()),
4786 (
4787 test_decoders_path.clone(),
4788 test_decoders_content.to_string(),
4789 ),
4790 (test_utils_path.clone(), test_utils_content.to_string()),
4791 (
4792 test_exported_path.clone(),
4793 test_exported_content.to_string(),
4794 ),
4795 ]
4796 .into_iter()
4797 .collect();
4798
4799 let extractor = PythonExtractor::new();
4800
4801 let result = extractor.map_test_files_with_imports(
4803 &production_files,
4804 &test_sources,
4805 dir.path(),
4806 false,
4807 );
4808
4809 let ground_truth_set: HashSet<(String, String)> = [
4815 (test_client_path.clone(), client_path.clone()),
4816 (test_decoders_path.clone(), decoders_path.clone()),
4817 (test_utils_path.clone(), utils_path.clone()),
4818 (test_exported_path.clone(), client_path.clone()),
4819 (test_exported_path.clone(), decoders_path.clone()),
4820 (test_exported_path.clone(), utils_path.clone()),
4821 ]
4822 .into_iter()
4823 .collect();
4824
4825 let actual_pairs: HashSet<(String, String)> = result
4826 .iter()
4827 .flat_map(|m| {
4828 m.test_files
4829 .iter()
4830 .map(|t| (t.clone(), m.production_file.clone()))
4831 .collect::<Vec<_>>()
4832 })
4833 .collect();
4834
4835 let tp = actual_pairs.intersection(&ground_truth_set).count();
4836 let fp = actual_pairs.difference(&ground_truth_set).count();
4837
4838 let precision = if tp + fp == 0 {
4840 0.0
4841 } else {
4842 tp as f64 / (tp + fp) as f64
4843 };
4844
4845 assert!(
4850 precision >= 0.80,
4851 "Precision {:.1}% < 80% target. TP={}, FP={}, actual_pairs={:?}",
4852 precision * 100.0,
4853 tp,
4854 fp,
4855 actual_pairs
4856 );
4857 }
4858
4859 #[test]
4864 fn py_af_01_assert_via_assigned_var() {
4865 let source = r#"
4867from pkg.client import Client
4868
4869def test_something():
4870 client = Client()
4871 assert client.ok
4872"#;
4873 let result = extract_assertion_referenced_imports(source);
4875
4876 assert!(
4878 result.contains("Client"),
4879 "Client should be in asserted_imports; got {:?}",
4880 result
4881 );
4882 }
4883
4884 #[test]
4889 fn py_af_02_setup_only_import_excluded() {
4890 let source = r#"
4892from pkg.client import Client
4893from pkg.transport import MockTransport
4894
4895def test_something():
4896 transport = MockTransport()
4897 client = Client(transport=transport)
4898 assert client.ok
4899"#;
4900 let result = extract_assertion_referenced_imports(source);
4902
4903 assert!(
4905 !result.contains("MockTransport"),
4906 "MockTransport should NOT be in asserted_imports (setup-only); got {:?}",
4907 result
4908 );
4909 assert!(
4911 result.contains("Client"),
4912 "Client should be in asserted_imports; got {:?}",
4913 result
4914 );
4915 }
4916
4917 #[test]
4922 fn py_af_03_direct_call_in_assertion() {
4923 let source = r#"
4925from pkg.models import A, B
4926
4927def test_equality():
4928 assert A() == B()
4929"#;
4930 let result = extract_assertion_referenced_imports(source);
4932
4933 assert!(
4935 result.contains("A"),
4936 "A should be in asserted_imports (used directly in assert); got {:?}",
4937 result
4938 );
4939 assert!(
4940 result.contains("B"),
4941 "B should be in asserted_imports (used directly in assert); got {:?}",
4942 result
4943 );
4944 }
4945
4946 #[test]
4951 fn py_af_04_pytest_raises_captures_exception_class() {
4952 let source = r#"
4954import pytest
4955from pkg.exceptions import HTTPError
4956
4957def test_raises():
4958 with pytest.raises(HTTPError):
4959 raise HTTPError("fail")
4960"#;
4961 let result = extract_assertion_referenced_imports(source);
4963
4964 assert!(
4966 result.contains("HTTPError"),
4967 "HTTPError should be in asserted_imports (pytest.raises arg); got {:?}",
4968 result
4969 );
4970 }
4971
4972 #[test]
4978 fn py_af_05_chain_tracking_two_hops() {
4979 let source = r#"
4981from pkg.client import Client
4982
4983def test_response():
4984 client = Client()
4985 response = client.get("http://example.com/")
4986 assert response.ok
4987"#;
4988 let result = extract_assertion_referenced_imports(source);
4990
4991 assert!(
4993 result.contains("Client"),
4994 "Client should be in asserted_imports via 2-hop chain; got {:?}",
4995 result
4996 );
4997 }
4998
4999 #[test]
5005 fn py_af_06a_no_assertions_returns_empty() {
5006 let source = r#"
5008from pkg.client import Client
5009from pkg.transport import MockTransport
5010
5011def test_setup_no_assert():
5012 client = Client()
5013 transport = MockTransport()
5014 # No assert statement at all
5015"#;
5016 let result = extract_assertion_referenced_imports(source);
5018
5019 assert!(
5022 result.is_empty(),
5023 "expected empty asserted_imports when no assertions present; got {:?}",
5024 result
5025 );
5026 }
5027
5028 #[test]
5034 fn py_af_06b_assertion_exists_but_no_import_intersection() {
5035 let source = r#"
5037from pkg.client import Client
5038
5039def test_local_only():
5040 local_value = 42
5041 # Assertion references only a local literal, not any imported symbol
5042 assert local_value == 42
5043"#;
5044 let result = extract_assertion_referenced_imports(source);
5046
5047 assert!(
5050 !result.contains("Client"),
5051 "Client should NOT be in asserted_imports (not referenced in assertion); got {:?}",
5052 result
5053 );
5054 }
5057
5058 #[test]
5064 fn py_af_07_unittest_self_assert() {
5065 let source = r#"
5067import unittest
5068from pkg.models import MyModel
5069
5070class TestMyModel(unittest.TestCase):
5071 def test_value(self):
5072 result = MyModel()
5073 self.assertEqual(result.value, 42)
5074"#;
5075 let result = extract_assertion_referenced_imports(source);
5077
5078 assert!(
5080 result.contains("MyModel"),
5081 "MyModel should be in asserted_imports via self.assertEqual; got {:?}",
5082 result
5083 );
5084 }
5085
5086 #[test]
5095 fn py_af_08_e2e_primary_kept_incidental_filtered() {
5096 use std::path::PathBuf;
5097 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5098 .parent()
5099 .unwrap()
5100 .parent()
5101 .unwrap()
5102 .join("tests/fixtures/python/observe/af_pkg");
5103
5104 let test_file = fixture_root
5105 .join("tests/test_client.py")
5106 .to_string_lossy()
5107 .into_owned();
5108 let client_prod = fixture_root
5109 .join("pkg/client.py")
5110 .to_string_lossy()
5111 .into_owned();
5112 let transport_prod = fixture_root
5113 .join("pkg/transport.py")
5114 .to_string_lossy()
5115 .into_owned();
5116
5117 let production_files = vec![client_prod.clone(), transport_prod.clone()];
5118 let test_source =
5119 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5120 let mut test_sources = HashMap::new();
5121 test_sources.insert(test_file.clone(), test_source);
5122
5123 let extractor = PythonExtractor::new();
5125 let result = extractor.map_test_files_with_imports(
5126 &production_files,
5127 &test_sources,
5128 &fixture_root,
5129 false,
5130 );
5131
5132 let client_mapping = result.iter().find(|m| m.production_file == client_prod);
5134 assert!(
5135 client_mapping.is_some(),
5136 "client.py should be in mappings; got {:?}",
5137 result
5138 .iter()
5139 .map(|m| &m.production_file)
5140 .collect::<Vec<_>>()
5141 );
5142 assert!(
5143 client_mapping.unwrap().test_files.contains(&test_file),
5144 "test_client.py should map to client.py"
5145 );
5146
5147 let transport_mapping = result.iter().find(|m| m.production_file == transport_prod);
5149 let transport_maps_test = transport_mapping
5150 .map(|m| m.test_files.contains(&test_file))
5151 .unwrap_or(false);
5152 assert!(
5153 !transport_maps_test,
5154 "test_client.py should NOT map to transport.py (assertion filter); got {:?}",
5155 result
5156 .iter()
5157 .map(|m| (&m.production_file, &m.test_files))
5158 .collect::<Vec<_>>()
5159 );
5160 }
5161
5162 #[test]
5172 fn py_af_09_e2e_all_incidental_fallback_no_fn() {
5173 use std::path::PathBuf;
5174 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5175 .parent()
5176 .unwrap()
5177 .parent()
5178 .unwrap()
5179 .join("tests/fixtures/python/observe/af_e2e_fallback");
5180
5181 let test_file = fixture_root
5182 .join("tests/test_helpers.py")
5183 .to_string_lossy()
5184 .into_owned();
5185 let helpers_prod = fixture_root
5186 .join("pkg/helpers.py")
5187 .to_string_lossy()
5188 .into_owned();
5189
5190 let production_files = vec![helpers_prod.clone()];
5191 let test_source =
5192 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5193 let mut test_sources = HashMap::new();
5194 test_sources.insert(test_file.clone(), test_source);
5195
5196 let extractor = PythonExtractor::new();
5198 let result = extractor.map_test_files_with_imports(
5199 &production_files,
5200 &test_sources,
5201 &fixture_root,
5202 false,
5203 );
5204
5205 let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
5207 assert!(
5208 helpers_mapping.is_some(),
5209 "helpers.py should be in mappings (fallback); got {:?}",
5210 result
5211 .iter()
5212 .map(|m| &m.production_file)
5213 .collect::<Vec<_>>()
5214 );
5215 assert!(
5216 helpers_mapping.unwrap().test_files.contains(&test_file),
5217 "test_helpers.py should map to helpers.py (fallback, no FN)"
5218 );
5219 }
5220
5221 #[test]
5235 fn py_af_10_e2e_http_client_primary_mapped() {
5236 use std::path::PathBuf;
5237 let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5238 .parent()
5239 .unwrap()
5240 .parent()
5241 .unwrap()
5242 .join("tests/fixtures/python/observe/af_e2e_http");
5243
5244 let test_file = fixture_root
5245 .join("tests/test_http_client.py")
5246 .to_string_lossy()
5247 .into_owned();
5248 let http_client_prod = fixture_root
5249 .join("pkg/http_client.py")
5250 .to_string_lossy()
5251 .into_owned();
5252 let exceptions_prod = fixture_root
5253 .join("pkg/exceptions.py")
5254 .to_string_lossy()
5255 .into_owned();
5256
5257 let production_files = vec![http_client_prod.clone(), exceptions_prod.clone()];
5258 let test_source =
5259 std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5260 let mut test_sources = HashMap::new();
5261 test_sources.insert(test_file.clone(), test_source);
5262
5263 let extractor = PythonExtractor::new();
5265 let result = extractor.map_test_files_with_imports(
5266 &production_files,
5267 &test_sources,
5268 &fixture_root,
5269 false,
5270 );
5271
5272 let http_client_mapping = result
5274 .iter()
5275 .find(|m| m.production_file == http_client_prod);
5276 assert!(
5277 http_client_mapping.is_some(),
5278 "http_client.py should be in mappings; got {:?}",
5279 result
5280 .iter()
5281 .map(|m| &m.production_file)
5282 .collect::<Vec<_>>()
5283 );
5284 assert!(
5285 http_client_mapping.unwrap().test_files.contains(&test_file),
5286 "test_http_client.py should map to http_client.py (primary SUT)"
5287 );
5288 }
5289
5290 #[test]
5294 fn py_e2e_helper_excluded_from_mappings() {
5295 let tmp = tempfile::tempdir().unwrap();
5298 let root = tmp.path();
5299
5300 let files: &[(&str, &str)] = &[
5302 ("pkg/__init__.py", ""),
5303 ("pkg/client.py", "class Client:\n def connect(self):\n return True\n"),
5304 ("tests/__init__.py", ""),
5305 ("tests/helpers.py", "def mock_client():\n return \"mock\"\n"),
5306 (
5307 "tests/test_client.py",
5308 "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",
5309 ),
5310 ];
5311 for (rel, content) in files {
5312 let path = root.join(rel);
5313 if let Some(parent) = path.parent() {
5314 std::fs::create_dir_all(parent).unwrap();
5315 }
5316 std::fs::write(&path, content).unwrap();
5317 }
5318
5319 let extractor = PythonExtractor::new();
5320
5321 let client_abs = root.join("pkg/client.py").to_string_lossy().into_owned();
5324 let helpers_abs = root.join("tests/helpers.py").to_string_lossy().into_owned();
5325 let production_files = vec![client_abs.clone(), helpers_abs.clone()];
5326
5327 let test_abs = root
5328 .join("tests/test_client.py")
5329 .to_string_lossy()
5330 .into_owned();
5331 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";
5332 let test_sources: HashMap<String, String> = [(test_abs.clone(), test_content.to_string())]
5333 .into_iter()
5334 .collect();
5335
5336 let mappings =
5338 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5339
5340 for m in &mappings {
5342 assert!(
5343 !m.production_file.contains("helpers.py"),
5344 "helpers.py should be excluded as test helper, but found in mapping: {:?}",
5345 m
5346 );
5347 }
5348
5349 let client_mapping = mappings
5351 .iter()
5352 .find(|m| m.production_file.contains("client.py"));
5353 assert!(
5354 client_mapping.is_some(),
5355 "pkg/client.py should be mapped; got {:?}",
5356 mappings
5357 .iter()
5358 .map(|m| &m.production_file)
5359 .collect::<Vec<_>>()
5360 );
5361 let client_mapping = client_mapping.unwrap();
5362 assert!(
5363 client_mapping
5364 .test_files
5365 .iter()
5366 .any(|t| t.contains("test_client.py")),
5367 "pkg/client.py should map to test_client.py; got {:?}",
5368 client_mapping.test_files
5369 );
5370 }
5371
5372 #[test]
5378 fn py_fp_01_mock_transport_fixture_not_mapped() {
5379 use tempfile::TempDir;
5380
5381 let dir = TempDir::new().unwrap();
5382 let root = dir.path();
5383 let pkg = root.join("pkg");
5384 let transports = pkg.join("_transports");
5385 let tests_dir = root.join("tests");
5386 std::fs::create_dir_all(&transports).unwrap();
5387 std::fs::create_dir_all(&tests_dir).unwrap();
5388
5389 std::fs::write(
5390 transports.join("mock.py"),
5391 "class MockTransport:\n pass\n",
5392 )
5393 .unwrap();
5394 std::fs::write(
5395 transports.join("__init__.py"),
5396 "from .mock import MockTransport\n",
5397 )
5398 .unwrap();
5399 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5400 std::fs::write(
5401 pkg.join("__init__.py"),
5402 "from ._transports import *\nfrom ._client import Client\n",
5403 )
5404 .unwrap();
5405
5406 let test_content = "import pkg\n\ndef test_hooks():\n client = pkg.Client(transport=pkg.MockTransport())\n assert client is not None\n";
5407 std::fs::write(tests_dir.join("test_hooks.py"), test_content).unwrap();
5408
5409 let mock_path = transports.join("mock.py").to_string_lossy().into_owned();
5410 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5411 let test_path = tests_dir
5412 .join("test_hooks.py")
5413 .to_string_lossy()
5414 .into_owned();
5415
5416 let extractor = PythonExtractor::new();
5417 let production_files = vec![mock_path.clone(), client_path.clone()];
5418 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5419 .into_iter()
5420 .collect();
5421
5422 let result =
5423 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5424
5425 let mock_mapping = result.iter().find(|m| m.production_file == mock_path);
5426 assert!(
5427 mock_mapping.is_none() || mock_mapping.unwrap().test_files.is_empty(),
5428 "mock.py should NOT be mapped (fixture); mappings={:?}",
5429 result
5430 );
5431 }
5432
5433 #[test]
5439 fn py_fp_02_version_py_incidental_not_mapped() {
5440 use tempfile::TempDir;
5441
5442 let dir = TempDir::new().unwrap();
5443 let root = dir.path();
5444 let pkg = root.join("pkg");
5445 let tests_dir = root.join("tests");
5446 std::fs::create_dir_all(&pkg).unwrap();
5447 std::fs::create_dir_all(&tests_dir).unwrap();
5448
5449 std::fs::write(pkg.join("__version__.py"), "__version__ = \"1.0.0\"\n").unwrap();
5450 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5451 std::fs::write(
5452 pkg.join("__init__.py"),
5453 "from .__version__ import __version__\nfrom ._client import Client\n",
5454 )
5455 .unwrap();
5456
5457 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";
5458 std::fs::write(tests_dir.join("test_headers.py"), test_content).unwrap();
5459
5460 let version_path = pkg.join("__version__.py").to_string_lossy().into_owned();
5461 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5462 let test_path = tests_dir
5463 .join("test_headers.py")
5464 .to_string_lossy()
5465 .into_owned();
5466
5467 let extractor = PythonExtractor::new();
5468 let production_files = vec![version_path.clone(), client_path.clone()];
5469 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5470 .into_iter()
5471 .collect();
5472
5473 let result =
5474 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5475
5476 let version_mapping = result.iter().find(|m| m.production_file == version_path);
5477 assert!(
5478 version_mapping.is_none() || version_mapping.unwrap().test_files.is_empty(),
5479 "__version__.py should NOT be mapped (metadata); mappings={:?}",
5480 result
5481 );
5482 }
5483
5484 #[test]
5490 fn py_fp_03_types_py_annotation_not_mapped() {
5491 use tempfile::TempDir;
5492
5493 let dir = TempDir::new().unwrap();
5494 let root = dir.path();
5495 let pkg = root.join("pkg");
5496 let tests_dir = root.join("tests");
5497 std::fs::create_dir_all(&pkg).unwrap();
5498 std::fs::create_dir_all(&tests_dir).unwrap();
5499
5500 std::fs::write(
5501 pkg.join("_types.py"),
5502 "from typing import Union\nQueryParamTypes = Union[str, dict]\n",
5503 )
5504 .unwrap();
5505 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5506 std::fs::write(
5507 pkg.join("__init__.py"),
5508 "from ._types import *\nfrom ._client import Client\n",
5509 )
5510 .unwrap();
5511
5512 let test_content = "import pkg\n\ndef test_client():\n client = pkg.Client()\n assert client is not None\n";
5513 std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5514
5515 let types_path = pkg.join("_types.py").to_string_lossy().into_owned();
5516 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5517 let test_path = tests_dir
5518 .join("test_client.py")
5519 .to_string_lossy()
5520 .into_owned();
5521
5522 let extractor = PythonExtractor::new();
5523 let production_files = vec![types_path.clone(), client_path.clone()];
5524 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5525 .into_iter()
5526 .collect();
5527
5528 let result =
5529 extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5530
5531 let types_mapping = result.iter().find(|m| m.production_file == types_path);
5532 assert!(
5533 types_mapping.is_none() || types_mapping.unwrap().test_files.is_empty(),
5534 "_types.py should NOT be mapped (type definitions); mappings={:?}",
5535 result
5536 );
5537 }
5538
5539 #[test]
5543 fn py_resolve_priority_01_file_wins_over_package() {
5544 let tmp = tempfile::tempdir().unwrap();
5546 let baz_dir = tmp.path().join("foo").join("bar").join("baz");
5547 std::fs::create_dir_all(&baz_dir).unwrap();
5548 let baz_file = tmp.path().join("foo").join("bar").join("baz.py");
5549 std::fs::write(&baz_file, "class Baz: pass\n").unwrap();
5550 let baz_init = baz_dir.join("__init__.py");
5551 std::fs::write(&baz_init, "from .impl import Baz\n").unwrap();
5552
5553 let canonical_root = tmp.path().canonicalize().unwrap();
5554 let base = tmp.path().join("foo").join("bar").join("baz");
5555 let extractor = PythonExtractor::new();
5556
5557 let result =
5559 exspec_core::observe::resolve_absolute_base_to_file(&extractor, &base, &canonical_root);
5560
5561 assert!(result.is_some(), "expected resolution, got None");
5563 let resolved = result.unwrap();
5564 assert!(
5565 resolved.ends_with("baz.py"),
5566 "expected baz.py (file wins over package), got: {resolved}"
5567 );
5568 assert!(
5569 !resolved.contains("__init__"),
5570 "should NOT resolve to __init__.py, got: {resolved}"
5571 );
5572 }
5573
5574 #[test]
5582 fn py_submod_01_direct_import_bypasses_assertion_filter() {
5583 use std::collections::HashMap;
5584 use tempfile::TempDir;
5585
5586 let dir = TempDir::new().unwrap();
5593 let pkg = dir.path().join("pkg");
5594 std::fs::create_dir_all(&pkg).unwrap();
5595 let tests_dir = dir.path().join("tests");
5596 std::fs::create_dir_all(&tests_dir).unwrap();
5597
5598 std::fs::write(pkg.join("_urlparse.py"), "def normalize(url): return url\n").unwrap();
5599 std::fs::write(pkg.join("_client.py"), "class URL:\n pass\n").unwrap();
5600 std::fs::write(pkg.join("__init__.py"), "from ._client import URL\n").unwrap();
5602
5603 let test_content = "from pkg._urlparse import normalize\nimport pkg\n\ndef test_url():\n assert pkg.URL(\"http://example.com\")\n";
5604 std::fs::write(tests_dir.join("test_whatwg.py"), test_content).unwrap();
5605
5606 let urlparse_path = pkg.join("_urlparse.py").to_string_lossy().into_owned();
5607 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5608 let test_path = tests_dir
5609 .join("test_whatwg.py")
5610 .to_string_lossy()
5611 .into_owned();
5612
5613 let extractor = PythonExtractor::new();
5614 let production_files = vec![urlparse_path.clone(), client_path.clone()];
5615 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5616 .into_iter()
5617 .collect();
5618
5619 let result = extractor.map_test_files_with_imports(
5621 &production_files,
5622 &test_sources,
5623 dir.path(),
5624 false,
5625 );
5626
5627 let urlparse_mapped = result
5629 .iter()
5630 .find(|m| m.production_file == urlparse_path)
5631 .map(|m| m.test_files.contains(&test_path))
5632 .unwrap_or(false);
5633 assert!(
5634 urlparse_mapped,
5635 "pkg/_urlparse.py should be mapped via direct import (assertion filter bypass). mappings={:?}",
5636 result
5637 );
5638 }
5639
5640 #[test]
5647 fn py_submod_02_unre_exported_direct_import_mapped() {
5648 use std::collections::HashMap;
5649 use tempfile::TempDir;
5650
5651 let dir = TempDir::new().unwrap();
5656 let pkg = dir.path().join("pkg");
5657 std::fs::create_dir_all(&pkg).unwrap();
5658 let tests_dir = dir.path().join("tests");
5659 std::fs::create_dir_all(&tests_dir).unwrap();
5660
5661 std::fs::write(pkg.join("_internal.py"), "def helper(): return True\n").unwrap();
5662 std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
5664
5665 let test_content =
5666 "from pkg._internal import helper\n\ndef test_it():\n assert helper()\n";
5667 std::fs::write(tests_dir.join("test_internal.py"), test_content).unwrap();
5668
5669 let internal_path = pkg.join("_internal.py").to_string_lossy().into_owned();
5670 let test_path = tests_dir
5671 .join("test_internal.py")
5672 .to_string_lossy()
5673 .into_owned();
5674
5675 let extractor = PythonExtractor::new();
5676 let production_files = vec![internal_path.clone()];
5677 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5678 .into_iter()
5679 .collect();
5680
5681 let result = extractor.map_test_files_with_imports(
5683 &production_files,
5684 &test_sources,
5685 dir.path(),
5686 false,
5687 );
5688
5689 let internal_mapped = result
5691 .iter()
5692 .find(|m| m.production_file == internal_path)
5693 .map(|m| m.test_files.contains(&test_path))
5694 .unwrap_or(false);
5695 assert!(
5696 internal_mapped,
5697 "pkg/_internal.py should be mapped via direct import. mappings={:?}",
5698 result
5699 );
5700 }
5701
5702 #[test]
5709 fn py_submod_03_nested_submodule_direct_import_mapped() {
5710 use std::collections::HashMap;
5711 use tempfile::TempDir;
5712
5713 let dir = TempDir::new().unwrap();
5718 let pkg = dir.path().join("pkg");
5719 let internal = pkg.join("_internal");
5720 std::fs::create_dir_all(&internal).unwrap();
5721 let tests_dir = dir.path().join("tests");
5722 std::fs::create_dir_all(&tests_dir).unwrap();
5723
5724 std::fs::write(internal.join("_helpers.py"), "def util(): return True\n").unwrap();
5725 std::fs::write(internal.join("__init__.py"), "# empty\n").unwrap();
5726 std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
5727
5728 let test_content =
5729 "from pkg._internal._helpers import util\n\ndef test_util():\n assert util()\n";
5730 std::fs::write(tests_dir.join("test_helpers.py"), test_content).unwrap();
5731
5732 let helpers_path = internal.join("_helpers.py").to_string_lossy().into_owned();
5733 let test_path = tests_dir
5734 .join("test_helpers.py")
5735 .to_string_lossy()
5736 .into_owned();
5737
5738 let extractor = PythonExtractor::new();
5739 let production_files = vec![helpers_path.clone()];
5740 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5741 .into_iter()
5742 .collect();
5743
5744 let result = extractor.map_test_files_with_imports(
5746 &production_files,
5747 &test_sources,
5748 dir.path(),
5749 false,
5750 );
5751
5752 let helpers_mapped = result
5754 .iter()
5755 .find(|m| m.production_file == helpers_path)
5756 .map(|m| m.test_files.contains(&test_path))
5757 .unwrap_or(false);
5758 assert!(
5759 helpers_mapped,
5760 "pkg/_internal/_helpers.py should be mapped via nested direct import. mappings={:?}",
5761 result
5762 );
5763 }
5764
5765 #[test]
5775 fn py_submod_05_non_bare_relative_direct_import_bypass() {
5776 use std::collections::HashMap;
5777 use tempfile::TempDir;
5778
5779 let dir = TempDir::new().unwrap();
5792 let pkg = dir.path().join("pkg");
5793 std::fs::create_dir_all(&pkg).unwrap();
5794
5795 std::fs::write(pkg.join("_config.py"), "class Config:\n pass\n").unwrap();
5796 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5797 std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
5799
5800 let test_content = "import pkg\nfrom ._config import Config\n\ndef test_something():\n assert pkg.Client()\n";
5801 std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
5802
5803 let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
5804 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5805 let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
5806
5807 let extractor = PythonExtractor::new();
5808 let production_files = vec![config_path.clone(), client_path.clone()];
5809 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5810 .into_iter()
5811 .collect();
5812
5813 let result = extractor.map_test_files_with_imports(
5815 &production_files,
5816 &test_sources,
5817 dir.path(),
5818 false,
5819 );
5820
5821 let config_mapped = result
5823 .iter()
5824 .find(|m| m.production_file == config_path)
5825 .map(|m| m.test_files.contains(&test_path))
5826 .unwrap_or(false);
5827 assert!(
5828 config_mapped,
5829 "pkg/_config.py should be mapped via non-bare relative direct import (assertion filter bypass). mappings={:?}",
5830 result
5831 );
5832 }
5833
5834 #[test]
5844 fn py_submod_06_bare_relative_direct_import_bypass() {
5845 use std::collections::HashMap;
5846 use tempfile::TempDir;
5847
5848 let dir = TempDir::new().unwrap();
5861 let pkg = dir.path().join("pkg");
5862 std::fs::create_dir_all(&pkg).unwrap();
5863
5864 std::fs::write(pkg.join("utils.py"), "def helper(): return True\n").unwrap();
5865 std::fs::write(pkg.join("_client.py"), "class Client:\n pass\n").unwrap();
5866 std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
5868
5869 let test_content =
5870 "import pkg\nfrom . import utils\n\ndef test_something():\n assert pkg.Client()\n";
5871 std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
5872
5873 let utils_path = pkg.join("utils.py").to_string_lossy().into_owned();
5874 let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5875 let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
5876
5877 let extractor = PythonExtractor::new();
5878 let production_files = vec![utils_path.clone(), client_path.clone()];
5879 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5880 .into_iter()
5881 .collect();
5882
5883 let result = extractor.map_test_files_with_imports(
5885 &production_files,
5886 &test_sources,
5887 dir.path(),
5888 false,
5889 );
5890
5891 let utils_mapped = result
5893 .iter()
5894 .find(|m| m.production_file == utils_path)
5895 .map(|m| m.test_files.contains(&test_path))
5896 .unwrap_or(false);
5897 assert!(
5898 utils_mapped,
5899 "pkg/utils.py should be mapped via bare relative direct import (assertion filter bypass). mappings={:?}",
5900 result
5901 );
5902 }
5903
5904 #[test]
5912 fn py_submod_04_regression_barrel_only_assertion_filter_preserved() {
5913 use std::collections::HashMap;
5914 use tempfile::TempDir;
5915
5916 let dir = TempDir::new().unwrap();
5923 let pkg = dir.path().join("pkg");
5924 std::fs::create_dir_all(&pkg).unwrap();
5925 let tests_dir = dir.path().join("tests");
5926 std::fs::create_dir_all(&tests_dir).unwrap();
5927
5928 std::fs::write(pkg.join("_config.py"), "class Config:\n pass\n").unwrap();
5929 std::fs::write(pkg.join("_models.py"), "class Model:\n pass\n").unwrap();
5930 std::fs::write(
5932 pkg.join("__init__.py"),
5933 "from ._config import Config\nfrom ._models import Model\n",
5934 )
5935 .unwrap();
5936
5937 let test_content = "import pkg\n\ndef test_foo():\n assert pkg.Config()\n";
5938 std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
5939
5940 let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
5941 let models_path = pkg.join("_models.py").to_string_lossy().into_owned();
5942 let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
5943
5944 let extractor = PythonExtractor::new();
5945 let production_files = vec![config_path.clone(), models_path.clone()];
5946 let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5947 .into_iter()
5948 .collect();
5949
5950 let result = extractor.map_test_files_with_imports(
5952 &production_files,
5953 &test_sources,
5954 dir.path(),
5955 false,
5956 );
5957
5958 let models_not_mapped = result
5960 .iter()
5961 .find(|m| m.production_file == models_path)
5962 .map(|m| !m.test_files.contains(&test_path))
5963 .unwrap_or(true);
5964 assert!(
5965 models_not_mapped,
5966 "pkg/_models.py should NOT be mapped (barrel import, no assertion on Model). mappings={:?}",
5967 result
5968 );
5969 }
5970}