Skip to main content

exspec_lang_python/
observe.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
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
42// ---------------------------------------------------------------------------
43// Stem helpers
44// ---------------------------------------------------------------------------
45
46/// Extract stem from a test file path.
47/// `test_user.py` -> `Some("user")`
48/// `user_test.py` -> `Some("user")`
49/// Other files -> `None`
50pub fn test_stem(path: &str) -> Option<&str> {
51    let file_name = Path::new(path).file_name()?.to_str()?;
52    // Must end with .py
53    let stem = file_name.strip_suffix(".py")?;
54    // test_*.py
55    if let Some(rest) = stem.strip_prefix("test_") {
56        return Some(rest);
57    }
58    // *_test.py
59    if let Some(rest) = stem.strip_suffix("_test") {
60        return Some(rest);
61    }
62    // Django: tests.py → parent directory name as stem
63    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
76/// Extract stem from a production file path.
77/// `user.py` -> `Some("user")`
78/// `_decoders.py` -> `Some("decoders")` (leading `_` stripped)
79/// `__init__.py` -> `None`
80/// `test_user.py` -> `None`
81pub 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    // Exclude __init__.py
85    if stem == "__init__" {
86        return None;
87    }
88    // Exclude Django tests.py
89    if stem == "tests" {
90        return None;
91    }
92    // Exclude test files
93    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
101/// Determine if a file is a non-SUT helper (should be excluded from mapping).
102pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
103    // Phase 20: Path-segment check BEFORE is_known_production bypass.
104    // Files inside tests/ or test/ directories that are NOT test files
105    // are always helpers, even if they appear in production_files list.
106    // (Same pattern as TypeScript observe.)
107    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    // Phase 21: Metadata/fixture/type-only files are always non-SUT helpers,
116    // even if they appear in production_files list.
117    // These files are frequently re-exported via barrels and cause FP fan-out.
118    let stem_only = Path::new(file_path)
119        .file_stem()
120        .and_then(|f| f.to_str())
121        .unwrap_or("");
122
123    // __version__.py: package metadata, not a SUT
124    if stem_only == "__version__" {
125        return true;
126    }
127
128    // _types.py / __types__.py: pure type-definition files
129    {
130        let normalized = stem_only.trim_matches('_');
131        if normalized == "types" || normalized.ends_with("_types") {
132            return true;
133        }
134    }
135
136    // mock.py / mock_*.py: test fixture/infrastructure
137    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    // Known helper filenames
151    if matches!(
152        file_name,
153        "conftest.py" | "constants.py" | "setup.py" | "__init__.py"
154    ) {
155        return true;
156    }
157
158    // __pycache__/ files are helpers
159    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
173// ---------------------------------------------------------------------------
174// Standalone helpers
175// ---------------------------------------------------------------------------
176
177/// Extract attribute names accessed on a bare-imported module.
178///
179/// For `import httpx; httpx.Client(); httpx.get()`, returns `["Client", "get"]`.
180/// Returns empty vec if no attribute accesses are found (fallback to full match).
181fn 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
216// ---------------------------------------------------------------------------
217// ObserveExtractor impl
218// ---------------------------------------------------------------------------
219
220impl 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        // Capture indices
235        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        // Indices that represent function names (any of these → fn_name)
243        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        // Indices that represent class names (any of these → class_name)
250        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            // Determine which pattern matched based on captures present
258            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        // Deduplicate: same name + class_name pair may appear from multiple patterns
286        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        // Collect raw (module_text, symbol_text) pairs
308        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            // Convert Python module path to specifier:
330            // Leading dots: `.` -> `./`, `..` -> `../`, etc.
331            // `from .models import X`  -> module_text might be ".models" or "models" depending on parse
332            // We need to handle tree-sitter-python's representation
333            let specifier_base = python_module_to_relative_specifier(&module_text);
334
335            // Only include relative imports in extract_imports
336            if specifier_base.starts_with("./") || specifier_base.starts_with("../") {
337                // `from . import views` case: specifier_base is "./" (no module part)
338                // In this case the symbol_name IS the module name, so specifier = "./{symbol}"
339                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        // Group by specifier: collect all symbols per specifier
352        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        // Build ImportMapping per symbol
361        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                    // Use the parent dotted_name node's text to reconstruct the full
408                    // module name (e.g., `os.path` from individual `identifier` captures).
409                    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                // bare import: `import X` or `import os.path`
421                // Dedup in case multiple identifier captures share the same dotted_name parent.
422                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            // Convert dotted module path to file path: `myapp.models` -> `myapp/models`
441            let specifier = python_module_to_absolute_specifier(&module_text);
442
443            // Skip relative imports (handled by extract_imports)
444            // Skip empty specifiers (relative-only, like `from . import X` with no module)
445            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        // Group symbols by from_specifier, tracking wildcard flag separately
477        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                // Only include relative re-exports
501                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, // If we can't read the file, assume it exports everything
553        };
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                // Detect __all__ existence via @var_name (pattern 1) or @all_decl (pattern 2)
576                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            // No __all__ defined: treat as exporting everything
588            return true;
589        }
590
591        // __all__ defined (possibly empty): check if any requested symbol is exported
592        symbols.iter().any(|s| all_symbols.contains(s))
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Module path conversion helpers
598// ---------------------------------------------------------------------------
599
600/// Convert a Python module specifier (as tree-sitter captures it) to a relative path specifier.
601///
602/// Tree-sitter-python represents `from .models import X` with module_name capturing `.models`
603/// and `from ..utils import Y` with `..utils`.
604/// `from . import views` has module_name capturing `.` (just dots).
605///
606/// We convert:
607/// - `.models`  -> `./models`
608/// - `..utils`  -> `../utils`
609/// - `.`        -> `.` (handled separately as `from . import X`)
610/// - `..`       -> `..` (handled separately)
611fn python_module_to_relative_specifier(module: &str) -> String {
612    // Count leading dots
613    let dot_count = module.chars().take_while(|&c| c == '.').count();
614    if dot_count == 0 {
615        // Not a relative import
616        return module.to_string();
617    }
618
619    let rest = &module[dot_count..];
620
621    if dot_count == 1 {
622        if rest.is_empty() {
623            // `from . import X` -> specifier will be derived from symbol name
624            // Return "./" as placeholder; caller uses the symbol as the path segment
625            "./".to_string()
626        } else {
627            format!("./{rest}")
628        }
629    } else {
630        // dot_count >= 2: `..` = `../`, `...` = `../../`, etc.
631        let prefix = "../".repeat(dot_count - 1);
632        if rest.is_empty() {
633            // `from .. import X`
634            prefix
635        } else {
636            format!("{prefix}{rest}")
637        }
638    }
639}
640
641/// Convert a Python absolute module path to a file-system path specifier.
642/// `myapp.models` -> `myapp/models`
643/// `.models`      -> (relative, skip)
644fn python_module_to_absolute_specifier(module: &str) -> String {
645    if module.starts_with('.') {
646        // Relative import - not handled here
647        return python_module_to_relative_specifier(module);
648    }
649    module.replace('.', "/")
650}
651
652// ---------------------------------------------------------------------------
653// Concrete methods (not in trait)
654// ---------------------------------------------------------------------------
655
656/// Search depth 1 and depth 2 subdirectories of `scan_root` for a `manage.py` file.
657///
658/// Returns the first subdirectory that contains `manage.py`, or `None` if
659/// `manage.py` exists at `scan_root` itself (already covered by canonical_root)
660/// or no subdirectory contains `manage.py`.
661pub fn find_manage_py_root(scan_root: &Path) -> Option<PathBuf> {
662    // scan_root itself has manage.py → already covered by canonical_root
663    if scan_root.join("manage.py").exists() {
664        return None;
665    }
666    // Depth 1
667    for entry in scan_root.read_dir().ok()?.flatten() {
668        let path = entry.path();
669        if path.is_dir() && path.join("manage.py").exists() {
670            return Some(path);
671        }
672    }
673    // Depth 2
674    for entry in scan_root.read_dir().ok()?.flatten() {
675        let path = entry.path();
676        if path.is_dir() {
677            for inner in path.read_dir().into_iter().flatten().flatten() {
678                let inner_path = inner.path();
679                if inner_path.is_dir() && inner_path.join("manage.py").exists() {
680                    return Some(inner_path);
681                }
682            }
683        }
684    }
685    None
686}
687
688/// Extract the set of import symbol names that appear (directly or via
689/// variable chain) inside assertion nodes.
690///
691/// Algorithm:
692/// 1. Parse source and find all assertion byte ranges via `assertion.scm`.
693/// 2. Walk the AST within each assertion range to collect all `identifier`
694///    leaf nodes → `assertion_identifiers`.
695/// 3. Parse assignment mappings via `assignment_mapping.scm`:
696///    - `@var` → `@class`  (direct: `var = ClassName()`)
697///    - `@var` → `@source` (chain: `var = obj.method()`)
698/// 4. Chain-expand `assertion_identifiers` up to 2 hops, resolving var →
699///    class via the assignment map.
700/// 5. Return the union of all resolved symbols.
701///
702/// Returns an empty `HashSet` when no assertions are found (caller is
703/// responsible for the safe fallback to `all_matched`).
704pub fn extract_assertion_referenced_imports(source: &str) -> HashSet<String> {
705    let mut parser = PythonExtractor::parser();
706    let tree = match parser.parse(source, None) {
707        Some(t) => t,
708        None => return HashSet::new(),
709    };
710    let source_bytes = source.as_bytes();
711
712    // ---- Step 1: collect assertion byte ranges ----
713    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
714    let assertion_cap_idx = match assertion_query.capture_index_for_name("assertion") {
715        Some(idx) => idx,
716        None => return HashSet::new(),
717    };
718
719    let mut assertion_ranges: Vec<(usize, usize)> = Vec::new();
720    {
721        let mut cursor = QueryCursor::new();
722        let mut matches = cursor.matches(assertion_query, tree.root_node(), source_bytes);
723        while let Some(m) = matches.next() {
724            for cap in m.captures {
725                if cap.index == assertion_cap_idx {
726                    let r = cap.node.byte_range();
727                    assertion_ranges.push((r.start, r.end));
728                }
729            }
730        }
731    }
732
733    if assertion_ranges.is_empty() {
734        return HashSet::new();
735    }
736
737    // ---- Step 2: collect identifiers within assertion ranges (AST walk) ----
738    let mut assertion_identifiers: HashSet<String> = HashSet::new();
739    {
740        let root = tree.root_node();
741        let mut stack = vec![root];
742        while let Some(node) = stack.pop() {
743            let nr = node.byte_range();
744            // Only descend into nodes that overlap with at least one assertion range
745            let overlaps = assertion_ranges
746                .iter()
747                .any(|&(s, e)| nr.start < e && nr.end > s);
748            if !overlaps {
749                continue;
750            }
751            if node.kind() == "identifier" {
752                // The identifier itself must be within an assertion range
753                if assertion_ranges
754                    .iter()
755                    .any(|&(s, e)| nr.start >= s && nr.end <= e)
756                {
757                    if let Ok(text) = node.utf8_text(source_bytes) {
758                        if !text.is_empty() {
759                            assertion_identifiers.insert(text.to_string());
760                        }
761                    }
762                }
763            }
764            for i in 0..node.child_count() {
765                if let Some(child) = node.child(i) {
766                    stack.push(child);
767                }
768            }
769        }
770    }
771
772    // ---- Step 3: build assignment map ----
773    // Maps var_name → set of resolved names (class or source object)
774    let assign_query = cached_query(&ASSIGNMENT_MAPPING_QUERY_CACHE, ASSIGNMENT_MAPPING_QUERY);
775    let var_idx = assign_query.capture_index_for_name("var");
776    let class_idx = assign_query.capture_index_for_name("class");
777    let source_idx = assign_query.capture_index_for_name("source");
778
779    // var → Vec<target_symbol>
780    let mut assignment_map: HashMap<String, Vec<String>> = HashMap::new();
781
782    if let (Some(var_cap), Some(class_cap), Some(source_cap)) = (var_idx, class_idx, source_idx) {
783        let mut cursor = QueryCursor::new();
784        let mut matches = cursor.matches(assign_query, tree.root_node(), source_bytes);
785        while let Some(m) = matches.next() {
786            let mut var_text = String::new();
787            let mut target_text = String::new();
788            for cap in m.captures {
789                if cap.index == var_cap {
790                    var_text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
791                } else if cap.index == class_cap || cap.index == source_cap {
792                    let t = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
793                    if !t.is_empty() {
794                        target_text = t;
795                    }
796                }
797            }
798            if !var_text.is_empty() && !target_text.is_empty() && var_text != target_text {
799                assignment_map
800                    .entry(var_text)
801                    .or_default()
802                    .push(target_text);
803            }
804        }
805    }
806
807    // ---- Step 4: chain-expand up to 2 hops ----
808    let mut resolved: HashSet<String> = assertion_identifiers.clone();
809    for _ in 0..2 {
810        let mut additions: HashSet<String> = HashSet::new();
811        for sym in &resolved {
812            if let Some(targets) = assignment_map.get(sym) {
813                for t in targets {
814                    additions.insert(t.clone());
815                }
816            }
817        }
818        let before = resolved.len();
819        resolved.extend(additions);
820        if resolved.len() == before {
821            break;
822        }
823    }
824
825    resolved
826}
827
828/// Track newly matched production-file indices and the symbols that caused
829/// them.  Called after each `collect_import_matches` invocation to update
830/// `idx_to_symbols` with the diff between `all_matched` before and after.
831fn track_new_matches(
832    all_matched: &HashSet<usize>,
833    before: &HashSet<usize>,
834    symbols: &[String],
835    idx_to_symbols: &mut HashMap<usize, HashSet<String>>,
836) {
837    for &new_idx in all_matched.difference(before) {
838        let entry = idx_to_symbols.entry(new_idx).or_default();
839        for s in symbols {
840            entry.insert(s.clone());
841        }
842    }
843}
844
845impl PythonExtractor {
846    /// Layer 1 + Layer 2: Map test files to production files.
847    pub fn map_test_files_with_imports(
848        &self,
849        production_files: &[String],
850        test_sources: &HashMap<String, String>,
851        scan_root: &Path,
852        l1_exclusive: bool,
853    ) -> Vec<FileMapping> {
854        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
855
856        // Phase 20: Filter out test-directory helper files from production_files before
857        // passing to Layer 1. Files inside tests/ or test/ path segments (relative to
858        // scan_root) are helpers (e.g. tests/helpers.py, tests/testserver/server.py)
859        // even when discover_files classifies them as production files.
860        // We strip the scan_root prefix to get the relative path for segment checking,
861        // avoiding false positives when the absolute path itself contains "tests" segments
862        // (e.g. /path/to/project/tests/fixtures/observe/e2e_pkg/views.py).
863        let canonical_root_for_filter = scan_root.canonicalize().ok();
864        let filtered_production_files: Vec<String> = production_files
865            .iter()
866            .filter(|p| {
867                let check_path = if let Some(ref root) = canonical_root_for_filter {
868                    if let Ok(canonical_p) = Path::new(p).canonicalize() {
869                        if let Ok(rel) = canonical_p.strip_prefix(root) {
870                            rel.to_string_lossy().into_owned()
871                        } else {
872                            p.to_string()
873                        }
874                    } else {
875                        p.to_string()
876                    }
877                } else {
878                    p.to_string()
879                };
880                !is_non_sut_helper(&check_path, false)
881            })
882            .cloned()
883            .collect();
884
885        // Layer 1: filename convention
886        let mut mappings =
887            exspec_core::observe::map_test_files(self, &filtered_production_files, &test_file_list);
888
889        // Build canonical path -> production index lookup
890        let canonical_root = match scan_root.canonicalize() {
891            Ok(r) => r,
892            Err(_) => return mappings,
893        };
894        let manage_py_root = find_manage_py_root(scan_root).and_then(|p| p.canonicalize().ok());
895        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
896        for (idx, prod) in filtered_production_files.iter().enumerate() {
897            if let Ok(canonical) = Path::new(prod).canonicalize() {
898                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
899            }
900        }
901
902        // Record Layer 1 core matches per production file index
903        let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
904            .iter()
905            .map(|m| m.test_files.iter().cloned().collect())
906            .collect();
907
908        // Layer 1 extension: stem-only fallback (cross-directory match)
909        // For test files that L1 core did not match, attempt stem-only match against prod files.
910        // Stem collision guard: if multiple prod files share the same stem, defer to L2 import tracing.
911        {
912            // Build stem -> list of production indices (stem stripped of leading `_`)
913            let mut stem_to_prod_indices: HashMap<String, Vec<usize>> = HashMap::new();
914            for (idx, prod) in filtered_production_files.iter().enumerate() {
915                if let Some(pstem) = self.production_stem(prod) {
916                    stem_to_prod_indices
917                        .entry(pstem.to_owned())
918                        .or_default()
919                        .push(idx);
920                }
921            }
922
923            // Collect set of test files already matched by L1 core (any prod)
924            let l1_core_matched: HashSet<&str> = layer1_tests_per_prod
925                .iter()
926                .flat_map(|s| s.iter().map(|t| t.as_str()))
927                .collect();
928
929            for test_file in &test_file_list {
930                // Skip if L1 core already matched this test file
931                if l1_core_matched.contains(test_file.as_str()) {
932                    continue;
933                }
934                if let Some(tstem) = self.test_stem(test_file) {
935                    if let Some(prod_indices) = stem_to_prod_indices.get(tstem) {
936                        if prod_indices.len() > 1 {
937                            continue; // stem collision: defer to L2 import tracing
938                        }
939                        for &idx in prod_indices {
940                            if !mappings[idx].test_files.contains(test_file) {
941                                mappings[idx].test_files.push(test_file.clone());
942                            }
943                        }
944                    }
945                }
946            }
947        }
948
949        // Snapshot L1 (core + stem-only fallback) matches per prod for strategy update
950        let layer1_extended_tests_per_prod: Vec<HashSet<String>> = mappings
951            .iter()
952            .map(|m| m.test_files.iter().cloned().collect())
953            .collect();
954
955        // Collect set of test files matched by L1 (core + stem-only fallback) for barrel suppression
956        let l1_matched_tests: HashSet<String> = mappings
957            .iter()
958            .flat_map(|m| m.test_files.iter().cloned())
959            .collect();
960
961        // Layer 2: import tracing
962        // Track production file indices matched ONLY via manage_py_root fallback
963        // (needed to upgrade strategy to ImportTracing for Django-layout projects)
964        let mut manage_py_only_prods: HashSet<usize> = HashSet::new();
965        for (test_file, source) in test_sources {
966            if l1_exclusive && l1_matched_tests.contains(test_file.as_str()) {
967                continue;
968            }
969            let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
970            let from_file = Path::new(test_file);
971            // all_matched: every idx matched by L2 (traditional behavior)
972            let mut all_matched = HashSet::<usize>::new();
973            // idx_to_symbols: tracks which import symbols caused each idx match
974            let mut idx_to_symbols: HashMap<usize, HashSet<String>> = HashMap::new();
975            // direct_import_indices: indices resolved via non-barrel L2 absolute import
976            // These bypass the assertion filter because `from pkg._sub import X` is a strong signal
977            let mut direct_import_indices: HashSet<usize> = HashSet::new();
978
979            for import in &imports {
980                // Handle bare relative imports: `from . import X` (specifier="./")
981                // or `from .. import X` (specifier="../"), etc.
982                // These need per-symbol resolution since the module part is the symbol name.
983                let is_bare_relative = (import.module_specifier == "./"
984                    || import.module_specifier.ends_with('/'))
985                    && import
986                        .module_specifier
987                        .trim_end_matches('/')
988                        .chars()
989                        .all(|c| c == '.');
990
991                let specifier = if is_bare_relative {
992                    let prefix =
993                        &import.module_specifier[..import.module_specifier.len().saturating_sub(1)];
994                    for sym in &import.symbols {
995                        let sym_specifier = format!("{prefix}/{sym}");
996                        if let Some(resolved) = exspec_core::observe::resolve_import_path(
997                            self,
998                            &sym_specifier,
999                            from_file,
1000                            &canonical_root,
1001                        ) {
1002                            // Barrel suppression: skip barrel-resolved imports for L1-matched tests
1003                            if self.is_barrel_file(&resolved)
1004                                && l1_matched_tests.contains(test_file.as_str())
1005                            {
1006                                continue;
1007                            }
1008                            let sym_slice = &[sym.clone()];
1009                            let before = all_matched.clone();
1010                            exspec_core::observe::collect_import_matches(
1011                                self,
1012                                &resolved,
1013                                sym_slice,
1014                                &canonical_to_idx,
1015                                &mut all_matched,
1016                                &canonical_root,
1017                            );
1018                            track_new_matches(
1019                                &all_matched,
1020                                &before,
1021                                sym_slice,
1022                                &mut idx_to_symbols,
1023                            );
1024                            // Direct (non-barrel) bare relative import → assertion filter bypass
1025                            // Note: barrel files are skipped above for L1-matched tests only.
1026                            // For non-L1-matched tests, barrel imports may reach here, but
1027                            // !is_barrel_file prevents them from being added to direct_import_indices.
1028                            if !self.is_barrel_file(&resolved) {
1029                                for &idx in all_matched.difference(&before) {
1030                                    direct_import_indices.insert(idx);
1031                                }
1032                            }
1033                        }
1034                    }
1035                    continue;
1036                } else {
1037                    import.module_specifier.clone()
1038                };
1039
1040                if let Some(resolved) = exspec_core::observe::resolve_import_path(
1041                    self,
1042                    &specifier,
1043                    from_file,
1044                    &canonical_root,
1045                ) {
1046                    // Barrel suppression: skip barrel-resolved imports for L1-matched tests
1047                    if self.is_barrel_file(&resolved)
1048                        && l1_matched_tests.contains(test_file.as_str())
1049                    {
1050                        continue;
1051                    }
1052                    let before = all_matched.clone();
1053                    exspec_core::observe::collect_import_matches(
1054                        self,
1055                        &resolved,
1056                        &import.symbols,
1057                        &canonical_to_idx,
1058                        &mut all_matched,
1059                        &canonical_root,
1060                    );
1061                    track_new_matches(&all_matched, &before, &import.symbols, &mut idx_to_symbols);
1062                    // Direct (non-barrel) non-bare relative import → assertion filter bypass
1063                    let is_direct = !self.is_barrel_file(&resolved);
1064                    if is_direct {
1065                        for &idx in all_matched.difference(&before) {
1066                            direct_import_indices.insert(idx);
1067                        }
1068                    }
1069                }
1070            }
1071
1072            // Layer 2 (absolute imports): resolve from scan_root
1073            let abs_specifiers = self.extract_all_import_specifiers(source);
1074            for (specifier, symbols) in &abs_specifiers {
1075                let base = canonical_root.join(specifier);
1076                let standard_resolved = exspec_core::observe::resolve_absolute_base_to_file(
1077                    self,
1078                    &base,
1079                    &canonical_root,
1080                )
1081                .or_else(|| {
1082                    let src_base = canonical_root.join("src").join(specifier);
1083                    exspec_core::observe::resolve_absolute_base_to_file(
1084                        self,
1085                        &src_base,
1086                        &canonical_root,
1087                    )
1088                });
1089                let via_manage_py = standard_resolved.is_none() && manage_py_root.is_some();
1090                let resolved = standard_resolved.or_else(|| {
1091                    if let Some(ref mpr) = manage_py_root {
1092                        let django_base = mpr.join(specifier);
1093                        exspec_core::observe::resolve_absolute_base_to_file(
1094                            self,
1095                            &django_base,
1096                            &canonical_root,
1097                        )
1098                    } else {
1099                        None
1100                    }
1101                });
1102                if let Some(resolved) = resolved {
1103                    // Barrel suppression: skip barrel-resolved imports for L1-matched tests
1104                    if self.is_barrel_file(&resolved)
1105                        && l1_matched_tests.contains(test_file.as_str())
1106                    {
1107                        continue;
1108                    }
1109                    // Direct (non-barrel) import: bypasses assertion filter (L1080)
1110                    // to avoid FN when test imports sub-module directly.
1111                    let is_direct = !self.is_barrel_file(&resolved);
1112                    let before = all_matched.clone();
1113                    exspec_core::observe::collect_import_matches(
1114                        self,
1115                        &resolved,
1116                        symbols,
1117                        &canonical_to_idx,
1118                        &mut all_matched,
1119                        &canonical_root,
1120                    );
1121                    track_new_matches(&all_matched, &before, symbols, &mut idx_to_symbols);
1122                    // Track direct (non-barrel) absolute import matches for assertion filter bypass
1123                    if is_direct {
1124                        for &idx in all_matched.difference(&before) {
1125                            direct_import_indices.insert(idx);
1126                        }
1127                    }
1128                    // Track manage_py_root-only matches separately
1129                    if via_manage_py && is_direct {
1130                        for &idx in all_matched.difference(&before) {
1131                            manage_py_only_prods.insert(idx);
1132                        }
1133                    }
1134                }
1135            }
1136
1137            // Assertion-referenced import filter (safe fallback)
1138            let asserted_imports = extract_assertion_referenced_imports(source);
1139            let final_indices: HashSet<usize> = if asserted_imports.is_empty() {
1140                // No assertions found -> fallback: use all_matched (PY-AF-06a)
1141                all_matched.clone()
1142            } else {
1143                // Filter to indices whose symbols intersect with asserted_imports
1144                let asserted_matched: HashSet<usize> = all_matched
1145                    .iter()
1146                    .copied()
1147                    .filter(|idx| {
1148                        idx_to_symbols
1149                            .get(idx)
1150                            .map(|syms| syms.iter().any(|s| asserted_imports.contains(s)))
1151                            .unwrap_or(false)
1152                    })
1153                    .collect();
1154                if asserted_matched.is_empty() {
1155                    // Assertions exist but no import symbol intersects -> safe fallback (PY-AF-06b, PY-AF-09)
1156                    all_matched.clone()
1157                } else {
1158                    // Include direct import indices regardless of assertion filter
1159                    let mut final_set = asserted_matched;
1160                    final_set.extend(direct_import_indices.intersection(&all_matched).copied());
1161                    final_set
1162                }
1163            };
1164
1165            for idx in final_indices {
1166                if !mappings[idx].test_files.contains(test_file) {
1167                    mappings[idx].test_files.push(test_file.clone());
1168                }
1169            }
1170        }
1171
1172        // Update strategy:
1173        // - If a production file had no Layer 1 matches but has L2 matches → ImportTracing
1174        // - If a production file was matched via manage_py_root fallback (Django layout),
1175        //   upgrade to ImportTracing regardless of L1 stem-only match
1176        for (i, mapping) in mappings.iter_mut().enumerate() {
1177            let has_layer1 = !layer1_extended_tests_per_prod[i].is_empty();
1178            if manage_py_only_prods.contains(&i) {
1179                // Matched via Django manage.py root fallback → ImportTracing
1180                mapping.strategy = MappingStrategy::ImportTracing;
1181            } else if !has_layer1 && !mapping.test_files.is_empty() {
1182                mapping.strategy = MappingStrategy::ImportTracing;
1183            }
1184        }
1185
1186        mappings
1187    }
1188}
1189
1190// ---------------------------------------------------------------------------
1191// Tests
1192// ---------------------------------------------------------------------------
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use std::path::PathBuf;
1198
1199    // -----------------------------------------------------------------------
1200    // PY-STEM-01: test_user.py -> test_stem = Some("user")
1201    // -----------------------------------------------------------------------
1202    #[test]
1203    fn py_stem_01_test_prefix() {
1204        // Given: a file named test_user.py
1205        // When: test_stem is called
1206        // Then: returns Some("user")
1207        let extractor = PythonExtractor::new();
1208        let result = extractor.test_stem("tests/test_user.py");
1209        assert_eq!(result, Some("user"));
1210    }
1211
1212    // -----------------------------------------------------------------------
1213    // PY-STEM-02: user_test.py -> test_stem = Some("user")
1214    // -----------------------------------------------------------------------
1215    #[test]
1216    fn py_stem_02_test_suffix() {
1217        // Given: a file named user_test.py
1218        // When: test_stem is called
1219        // Then: returns Some("user")
1220        let extractor = PythonExtractor::new();
1221        let result = extractor.test_stem("tests/user_test.py");
1222        assert_eq!(result, Some("user"));
1223    }
1224
1225    // -----------------------------------------------------------------------
1226    // PY-STEM-03: test_user_service.py -> test_stem = Some("user_service")
1227    // -----------------------------------------------------------------------
1228    #[test]
1229    fn py_stem_03_test_prefix_multi_segment() {
1230        // Given: a file named test_user_service.py
1231        // When: test_stem is called
1232        // Then: returns Some("user_service")
1233        let extractor = PythonExtractor::new();
1234        let result = extractor.test_stem("tests/test_user_service.py");
1235        assert_eq!(result, Some("user_service"));
1236    }
1237
1238    // -----------------------------------------------------------------------
1239    // PY-STEM-04: user.py -> production_stem = Some("user")
1240    // -----------------------------------------------------------------------
1241    #[test]
1242    fn py_stem_04_production_stem_regular() {
1243        // Given: a regular production file user.py
1244        // When: production_stem is called
1245        // Then: returns Some("user")
1246        let extractor = PythonExtractor::new();
1247        let result = extractor.production_stem("src/user.py");
1248        assert_eq!(result, Some("user"));
1249    }
1250
1251    // -----------------------------------------------------------------------
1252    // PY-STEM-05: __init__.py -> production_stem = None
1253    // -----------------------------------------------------------------------
1254    #[test]
1255    fn py_stem_05_production_stem_init() {
1256        // Given: __init__.py (barrel file)
1257        // When: production_stem is called
1258        // Then: returns None
1259        let extractor = PythonExtractor::new();
1260        let result = extractor.production_stem("src/__init__.py");
1261        assert_eq!(result, None);
1262    }
1263
1264    // -----------------------------------------------------------------------
1265    // PY-STEM-06: test_user.py -> production_stem = None
1266    // -----------------------------------------------------------------------
1267    #[test]
1268    fn py_stem_06_production_stem_test_file() {
1269        // Given: a test file test_user.py
1270        // When: production_stem is called
1271        // Then: returns None (test files are not production)
1272        let extractor = PythonExtractor::new();
1273        let result = extractor.production_stem("tests/test_user.py");
1274        assert_eq!(result, None);
1275    }
1276
1277    // -----------------------------------------------------------------------
1278    // PY-HELPER-01: conftest.py -> is_non_sut_helper = true
1279    // -----------------------------------------------------------------------
1280    #[test]
1281    fn py_helper_01_conftest() {
1282        // Given: conftest.py
1283        // When: is_non_sut_helper is called
1284        // Then: returns true
1285        let extractor = PythonExtractor::new();
1286        assert!(extractor.is_non_sut_helper("tests/conftest.py", false));
1287    }
1288
1289    // -----------------------------------------------------------------------
1290    // PY-HELPER-02: constants.py -> is_non_sut_helper = true
1291    // -----------------------------------------------------------------------
1292    #[test]
1293    fn py_helper_02_constants() {
1294        // Given: constants.py
1295        // When: is_non_sut_helper is called
1296        // Then: returns true
1297        let extractor = PythonExtractor::new();
1298        assert!(extractor.is_non_sut_helper("src/constants.py", false));
1299    }
1300
1301    // -----------------------------------------------------------------------
1302    // PY-HELPER-03: __init__.py -> is_non_sut_helper = true
1303    // -----------------------------------------------------------------------
1304    #[test]
1305    fn py_helper_03_init() {
1306        // Given: __init__.py
1307        // When: is_non_sut_helper is called
1308        // Then: returns true
1309        let extractor = PythonExtractor::new();
1310        assert!(extractor.is_non_sut_helper("src/__init__.py", false));
1311    }
1312
1313    // -----------------------------------------------------------------------
1314    // PY-HELPER-04: tests/utils.py -> is_non_sut_helper = true
1315    // -----------------------------------------------------------------------
1316    #[test]
1317    fn py_helper_04_utils_under_tests_dir() {
1318        // Given: utils.py under tests/ directory (not a test file)
1319        // When: is_non_sut_helper is called
1320        // Then: returns true
1321        let extractor = PythonExtractor::new();
1322        assert!(extractor.is_non_sut_helper("tests/utils.py", false));
1323    }
1324
1325    // -----------------------------------------------------------------------
1326    // PY-HELPER-05: models.py -> is_non_sut_helper = false
1327    // -----------------------------------------------------------------------
1328    #[test]
1329    fn py_helper_05_models_is_not_helper() {
1330        // Given: models.py (regular production file)
1331        // When: is_non_sut_helper is called
1332        // Then: returns false
1333        let extractor = PythonExtractor::new();
1334        assert!(!extractor.is_non_sut_helper("src/models.py", false));
1335    }
1336
1337    // -----------------------------------------------------------------------
1338    // PY-HELPER-06: tests/common.py -> helper even when is_known_production=true
1339    // -----------------------------------------------------------------------
1340    #[test]
1341    fn py_helper_06_tests_common_helper_despite_known_production() {
1342        // Given: file is tests/common.py with is_known_production=true
1343        // When: is_non_sut_helper is called
1344        // Then: returns true (path segment check overrides is_known_production)
1345        let extractor = PythonExtractor::new();
1346        assert!(extractor.is_non_sut_helper("tests/common.py", true));
1347    }
1348
1349    // -----------------------------------------------------------------------
1350    // PY-HELPER-07: tests/testserver/server.py -> helper (subdirectory of tests/)
1351    // -----------------------------------------------------------------------
1352    #[test]
1353    fn py_helper_07_tests_subdirectory_helper() {
1354        // Given: file is tests/testserver/server.py (inside tests/ dir but not a test file)
1355        // When: is_non_sut_helper is called
1356        // Then: returns true (path segment check catches subdirectories)
1357        let extractor = PythonExtractor::new();
1358        assert!(extractor.is_non_sut_helper("tests/testserver/server.py", true));
1359    }
1360
1361    // -----------------------------------------------------------------------
1362    // PY-HELPER-08: tests/compat.py -> helper (is_known_production=false)
1363    // -----------------------------------------------------------------------
1364    #[test]
1365    fn py_helper_08_tests_compat_helper() {
1366        // Given: file is tests/compat.py (inside tests/ dir, not a test file)
1367        // When: is_non_sut_helper is called
1368        // Then: returns true
1369        let extractor = PythonExtractor::new();
1370        assert!(extractor.is_non_sut_helper("tests/compat.py", false));
1371    }
1372
1373    // -----------------------------------------------------------------------
1374    // PY-HELPER-09: tests/fixtures/data.py -> helper (deep nesting inside tests/)
1375    // -----------------------------------------------------------------------
1376    #[test]
1377    fn py_helper_09_deep_nested_test_dir_helper() {
1378        // Given: file is tests/fixtures/data.py (deeply nested inside tests/)
1379        // When: is_non_sut_helper is called
1380        // Then: returns true (path segment check catches any depth under tests/)
1381        let extractor = PythonExtractor::new();
1382        assert!(extractor.is_non_sut_helper("tests/fixtures/data.py", false));
1383    }
1384
1385    // -----------------------------------------------------------------------
1386    // PY-HELPER-10: src/tests.py -> NOT helper (filename not dir segment)
1387    // -----------------------------------------------------------------------
1388    #[test]
1389    fn py_helper_10_tests_in_filename_not_helper() {
1390        // Given: file is src/tests.py ("tests" is in filename, not a directory segment)
1391        // When: is_non_sut_helper is called
1392        // Then: returns false (path segment check must not match filename)
1393        let extractor = PythonExtractor::new();
1394        assert!(!extractor.is_non_sut_helper("src/tests.py", false));
1395    }
1396
1397    // -----------------------------------------------------------------------
1398    // PY-HELPER-11: test/helpers.py -> helper (test/ singular directory)
1399    // -----------------------------------------------------------------------
1400    #[test]
1401    fn py_helper_11_test_singular_dir_helper() {
1402        // Given: file is test/helpers.py (singular "test" directory, not "tests")
1403        // When: is_non_sut_helper is called
1404        // Then: returns true (segment check matches both "tests" and "test")
1405        let extractor = PythonExtractor::new();
1406        assert!(extractor.is_non_sut_helper("test/helpers.py", true));
1407    }
1408
1409    // -----------------------------------------------------------------------
1410    // PY-BARREL-01: __init__.py -> is_barrel_file = true
1411    // -----------------------------------------------------------------------
1412    #[test]
1413    fn py_barrel_01_init_is_barrel() {
1414        // Given: __init__.py
1415        // When: is_barrel_file is called
1416        // Then: returns true
1417        let extractor = PythonExtractor::new();
1418        assert!(extractor.is_barrel_file("src/mypackage/__init__.py"));
1419    }
1420
1421    // -----------------------------------------------------------------------
1422    // PY-FUNC-01: def create_user() -> name="create_user", class_name=None
1423    // -----------------------------------------------------------------------
1424    #[test]
1425    fn py_func_01_top_level_function() {
1426        // Given: Python source with a top-level function
1427        let source = r#"
1428def create_user():
1429    pass
1430"#;
1431        // When: extract_production_functions is called
1432        let extractor = PythonExtractor::new();
1433        let result = extractor.extract_production_functions(source, "src/users.py");
1434
1435        // Then: name="create_user", class_name=None
1436        let func = result.iter().find(|f| f.name == "create_user");
1437        assert!(func.is_some(), "create_user not found in {:?}", result);
1438        let func = func.unwrap();
1439        assert_eq!(func.class_name, None);
1440    }
1441
1442    // -----------------------------------------------------------------------
1443    // PY-FUNC-02: class User: def save(self) -> name="save", class_name=Some("User")
1444    // -----------------------------------------------------------------------
1445    #[test]
1446    fn py_func_02_class_method() {
1447        // Given: Python source with a class containing a method
1448        let source = r#"
1449class User:
1450    def save(self):
1451        pass
1452"#;
1453        // When: extract_production_functions is called
1454        let extractor = PythonExtractor::new();
1455        let result = extractor.extract_production_functions(source, "src/models.py");
1456
1457        // Then: name="save", class_name=Some("User")
1458        let method = result.iter().find(|f| f.name == "save");
1459        assert!(method.is_some(), "save not found in {:?}", result);
1460        let method = method.unwrap();
1461        assert_eq!(method.class_name, Some("User".to_string()));
1462    }
1463
1464    // -----------------------------------------------------------------------
1465    // PY-FUNC-03: @decorator def endpoint() -> extracted
1466    // -----------------------------------------------------------------------
1467    #[test]
1468    fn py_func_03_decorated_function() {
1469        // Given: Python source with a decorated function
1470        let source = r#"
1471import functools
1472
1473def my_decorator(func):
1474    @functools.wraps(func)
1475    def wrapper(*args, **kwargs):
1476        return func(*args, **kwargs)
1477    return wrapper
1478
1479@my_decorator
1480def endpoint():
1481    pass
1482"#;
1483        // When: extract_production_functions is called
1484        let extractor = PythonExtractor::new();
1485        let result = extractor.extract_production_functions(source, "src/views.py");
1486
1487        // Then: endpoint is extracted
1488        let func = result.iter().find(|f| f.name == "endpoint");
1489        assert!(func.is_some(), "endpoint not found in {:?}", result);
1490    }
1491
1492    // -----------------------------------------------------------------------
1493    // PY-IMP-01: from .models import User -> specifier="./models", symbols=["User"]
1494    // -----------------------------------------------------------------------
1495    #[test]
1496    fn py_imp_01_relative_import_from_dot() {
1497        // Given: source with relative import from .models
1498        let source = "from .models import User\n";
1499
1500        // When: extract_imports is called
1501        let extractor = PythonExtractor::new();
1502        let result = extractor.extract_imports(source, "tests/test_user.py");
1503
1504        // Then: one entry with specifier="./models", symbols=["User"]
1505        let imp = result.iter().find(|i| i.module_specifier == "./models");
1506        assert!(
1507            imp.is_some(),
1508            "import from ./models not found in {:?}",
1509            result
1510        );
1511        let imp = imp.unwrap();
1512        assert!(
1513            imp.symbols.contains(&"User".to_string()),
1514            "User not in symbols: {:?}",
1515            imp.symbols
1516        );
1517    }
1518
1519    // -----------------------------------------------------------------------
1520    // PY-IMP-02: from ..utils import helper -> specifier="../utils", symbols=["helper"]
1521    // -----------------------------------------------------------------------
1522    #[test]
1523    fn py_imp_02_relative_import_two_dots() {
1524        // Given: source with two-dot relative import
1525        let source = "from ..utils import helper\n";
1526
1527        // When: extract_imports is called
1528        let extractor = PythonExtractor::new();
1529        let result = extractor.extract_imports(source, "tests/unit/test_something.py");
1530
1531        // Then: one entry with specifier="../utils", symbols=["helper"]
1532        let imp = result.iter().find(|i| i.module_specifier == "../utils");
1533        assert!(
1534            imp.is_some(),
1535            "import from ../utils not found in {:?}",
1536            result
1537        );
1538        let imp = imp.unwrap();
1539        assert!(
1540            imp.symbols.contains(&"helper".to_string()),
1541            "helper not in symbols: {:?}",
1542            imp.symbols
1543        );
1544    }
1545
1546    // -----------------------------------------------------------------------
1547    // PY-IMP-03: from myapp.models import User -> ("myapp/models", ["User"])
1548    // -----------------------------------------------------------------------
1549    #[test]
1550    fn py_imp_03_absolute_import_dotted() {
1551        // Given: source with absolute import using dotted module path
1552        let source = "from myapp.models import User\n";
1553
1554        // When: extract_all_import_specifiers is called
1555        let extractor = PythonExtractor::new();
1556        let result = extractor.extract_all_import_specifiers(source);
1557
1558        // Then: contains ("myapp/models", ["User"])
1559        let entry = result.iter().find(|(spec, _)| spec == "myapp/models");
1560        assert!(entry.is_some(), "myapp/models not found in {:?}", result);
1561        let (_, symbols) = entry.unwrap();
1562        assert!(
1563            symbols.contains(&"User".to_string()),
1564            "User not in symbols: {:?}",
1565            symbols
1566        );
1567    }
1568
1569    // -----------------------------------------------------------------------
1570    // PY-IMP-04: import os -> not resolved (skipped)
1571    // -----------------------------------------------------------------------
1572    #[test]
1573    fn py_imp_04_plain_import_skipped() {
1574        // Given: source with a plain stdlib import
1575        let source = "import os\n";
1576
1577        // When: extract_all_import_specifiers is called
1578        let extractor = PythonExtractor::new();
1579        let result = extractor.extract_all_import_specifiers(source);
1580
1581        // Then: "os" is present with empty symbols (bare import produces no symbol constraints)
1582        let os_entry = result.iter().find(|(spec, _)| spec == "os");
1583        assert!(
1584            os_entry.is_some(),
1585            "plain 'import os' should be included as bare import, got {:?}",
1586            result
1587        );
1588        let (_, symbols) = os_entry.unwrap();
1589        assert!(
1590            symbols.is_empty(),
1591            "expected empty symbols for bare import, got {:?}",
1592            symbols
1593        );
1594    }
1595
1596    // -----------------------------------------------------------------------
1597    // PY-IMP-05: from . import views -> specifier="./views", symbols=["views"]
1598    // -----------------------------------------------------------------------
1599    #[test]
1600    fn py_imp_05_from_dot_import_name() {
1601        // Given: source with `from . import views`
1602        let source = "from . import views\n";
1603
1604        // When: extract_imports is called
1605        let extractor = PythonExtractor::new();
1606        let result = extractor.extract_imports(source, "tests/test_app.py");
1607
1608        // Then: specifier="./views", symbols=["views"]
1609        let imp = result.iter().find(|i| i.module_specifier == "./views");
1610        assert!(imp.is_some(), "./views not found in {:?}", result);
1611        let imp = imp.unwrap();
1612        assert!(
1613            imp.symbols.contains(&"views".to_string()),
1614            "views not in symbols: {:?}",
1615            imp.symbols
1616        );
1617    }
1618
1619    // -----------------------------------------------------------------------
1620    // PY-IMPORT-01: `import httpx` -> specifier="httpx", symbols=[]
1621    // -----------------------------------------------------------------------
1622    #[test]
1623    fn py_import_01_bare_import_simple() {
1624        // Given: source with a bare import of a third-party package
1625        let source = "import httpx\n";
1626
1627        // When: extract_all_import_specifiers is called
1628        let extractor = PythonExtractor::new();
1629        let result = extractor.extract_all_import_specifiers(source);
1630
1631        // Then: contains ("httpx", []) -- bare import produces empty symbols
1632        let entry = result.iter().find(|(spec, _)| spec == "httpx");
1633        assert!(
1634            entry.is_some(),
1635            "httpx not found in {:?}; bare import should be included",
1636            result
1637        );
1638        let (_, symbols) = entry.unwrap();
1639        assert!(
1640            symbols.is_empty(),
1641            "expected empty symbols for bare import, got {:?}",
1642            symbols
1643        );
1644    }
1645
1646    // -----------------------------------------------------------------------
1647    // PY-IMPORT-02: `import os.path` -> specifier="os/path", symbols=[]
1648    // -----------------------------------------------------------------------
1649    #[test]
1650    fn py_import_01b_bare_import_attribute_access_narrowing() {
1651        // Given: source with bare import + attribute access (simple, non-dotted)
1652        let source = "import httpx\nhttpx.Client()\nhttpx.get('/api')\n";
1653
1654        // When: extract_all_import_specifiers is called
1655        let extractor = PythonExtractor::new();
1656        let result = extractor.extract_all_import_specifiers(source);
1657
1658        // Then: contains ("httpx", ["Client", "get"]) -- attribute access narrows symbols
1659        let entry = result.iter().find(|(spec, _)| spec == "httpx");
1660        assert!(entry.is_some(), "httpx not found in {:?}", result);
1661        let (_, symbols) = entry.unwrap();
1662        assert!(
1663            symbols.contains(&"Client".to_string()),
1664            "expected Client in symbols, got {:?}",
1665            symbols
1666        );
1667        assert!(
1668            symbols.contains(&"get".to_string()),
1669            "expected get in symbols, got {:?}",
1670            symbols
1671        );
1672    }
1673
1674    // -----------------------------------------------------------------------
1675    // PY-IMPORT-02a: `import os.path; os.path.join(...)` -> specifier="os/path", symbols=[]
1676    //   Dotted bare import attribute-access fallback: @module_name captures single
1677    //   identifier "os" but import_name_parts[0] is "os.path", so no match → empty symbols.
1678    //   This is intentional: fallback to match-all is the safe side.
1679    // -----------------------------------------------------------------------
1680    #[test]
1681    fn py_import_02a_dotted_bare_import_attribute_fallback() {
1682        // Given: source with dotted bare import + attribute access
1683        let source = "import os.path\nos.path.join('/a', 'b')\n";
1684
1685        // When: extract_all_import_specifiers is called
1686        let extractor = PythonExtractor::new();
1687        let result = extractor.extract_all_import_specifiers(source);
1688
1689        // Then: specifier="os/path", symbols=[] (fallback: tree-sitter @module_name captures
1690        //   "os" (single identifier) but import_name_parts[0] is "os.path", so mismatch → empty)
1691        let entry = result.iter().find(|(spec, _)| spec == "os/path");
1692        assert!(entry.is_some(), "os/path not found in {:?}", result);
1693        let (_, symbols) = entry.unwrap();
1694        assert!(
1695            symbols.is_empty(),
1696            "expected empty symbols for dotted bare import (intentional fallback), got {:?}",
1697            symbols
1698        );
1699    }
1700
1701    // -----------------------------------------------------------------------
1702    // PY-IMPORT-02: `import os.path` -> specifier="os/path", symbols=[]
1703    // -----------------------------------------------------------------------
1704    #[test]
1705    fn py_import_02_bare_import_dotted() {
1706        // Given: source with a dotted bare import
1707        let source = "import os.path\n";
1708
1709        // When: extract_all_import_specifiers is called
1710        let extractor = PythonExtractor::new();
1711        let result = extractor.extract_all_import_specifiers(source);
1712
1713        // Then: contains ("os/path", []) -- dots converted to slashes
1714        let entry = result.iter().find(|(spec, _)| spec == "os/path");
1715        assert!(
1716            entry.is_some(),
1717            "os/path not found in {:?}; dotted bare import should be converted",
1718            result
1719        );
1720        let (_, symbols) = entry.unwrap();
1721        assert!(
1722            symbols.is_empty(),
1723            "expected empty symbols for dotted bare import, got {:?}",
1724            symbols
1725        );
1726    }
1727
1728    // -----------------------------------------------------------------------
1729    // PY-IMPORT-03: `from httpx import Client` -> specifier="httpx", symbols=["Client"]
1730    //               (regression: from-import still works after bare-import change)
1731    // -----------------------------------------------------------------------
1732    #[test]
1733    fn py_import_03_from_import_regression() {
1734        // Given: source with a from-import (existing behaviour must not regress)
1735        let source = "from httpx import Client\n";
1736
1737        // When: extract_all_import_specifiers is called
1738        let extractor = PythonExtractor::new();
1739        let result = extractor.extract_all_import_specifiers(source);
1740
1741        // Then: contains ("httpx", ["Client"])
1742        let entry = result.iter().find(|(spec, _)| spec == "httpx");
1743        assert!(entry.is_some(), "httpx not found in {:?}", result);
1744        let (_, symbols) = entry.unwrap();
1745        assert!(
1746            symbols.contains(&"Client".to_string()),
1747            "Client not in symbols: {:?}",
1748            symbols
1749        );
1750    }
1751
1752    // -----------------------------------------------------------------------
1753    // PY-BARREL-02: __init__.py with `from .module import Foo`
1754    //               -> extract_barrel_re_exports: symbols=["Foo"], from_specifier="./module"
1755    // -----------------------------------------------------------------------
1756    #[test]
1757    fn py_barrel_02_re_export_named() {
1758        // Given: __init__.py content with a named re-export
1759        let source = "from .module import Foo\n";
1760
1761        // When: extract_barrel_re_exports is called
1762        let extractor = PythonExtractor::new();
1763        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1764
1765        // Then: one entry with symbols=["Foo"], from_specifier="./module"
1766        let entry = result.iter().find(|e| e.from_specifier == "./module");
1767        assert!(entry.is_some(), "./module not found in {:?}", result);
1768        let entry = entry.unwrap();
1769        assert!(
1770            entry.symbols.contains(&"Foo".to_string()),
1771            "Foo not in symbols: {:?}",
1772            entry.symbols
1773        );
1774    }
1775
1776    // -----------------------------------------------------------------------
1777    // PY-BARREL-03: __all__ = ["Foo"] -> file_exports_any_symbol(["Foo"]) = true
1778    // -----------------------------------------------------------------------
1779    #[test]
1780    fn py_barrel_03_all_exports_symbol_present() {
1781        // Given: a file with __all__ = ["Foo"]
1782        // (we use the fixture file)
1783        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1784            .parent()
1785            .unwrap()
1786            .parent()
1787            .unwrap()
1788            .join("tests/fixtures/python/observe/barrel/__init__.py");
1789
1790        // When: file_exports_any_symbol is called with ["Foo"]
1791        let extractor = PythonExtractor::new();
1792        let symbols = vec!["Foo".to_string()];
1793        let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1794
1795        // Then: returns true
1796        assert!(
1797            result,
1798            "expected file_exports_any_symbol to return true for Foo"
1799        );
1800    }
1801
1802    // -----------------------------------------------------------------------
1803    // PY-BARREL-04: __all__ = ["Foo"] -> file_exports_any_symbol(["Bar"]) = false
1804    // -----------------------------------------------------------------------
1805    #[test]
1806    fn py_barrel_04_all_exports_symbol_absent() {
1807        // Given: a file with __all__ = ["Foo"]
1808        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1809            .parent()
1810            .unwrap()
1811            .parent()
1812            .unwrap()
1813            .join("tests/fixtures/python/observe/barrel/__init__.py");
1814
1815        // When: file_exports_any_symbol is called with ["Bar"]
1816        let extractor = PythonExtractor::new();
1817        let symbols = vec!["Bar".to_string()];
1818        let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1819
1820        // Then: returns false
1821        assert!(
1822            !result,
1823            "expected file_exports_any_symbol to return false for Bar"
1824        );
1825    }
1826
1827    // -----------------------------------------------------------------------
1828    // PY-BARREL-05: `from .module import *` extracts wildcard=true
1829    // -----------------------------------------------------------------------
1830    #[test]
1831    fn py_barrel_05_re_export_wildcard() {
1832        // Given: __init__.py content with a wildcard re-export
1833        let source = "from .module import *\n";
1834
1835        // When: extract_barrel_re_exports is called
1836        let extractor = PythonExtractor::new();
1837        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1838
1839        // Then: one entry with wildcard=true, from_specifier="./module", empty symbols
1840        let entry = result.iter().find(|e| e.from_specifier == "./module");
1841        assert!(entry.is_some(), "./module not found in {:?}", result);
1842        let entry = entry.unwrap();
1843        assert!(entry.wildcard, "expected wildcard=true, got {:?}", entry);
1844        assert!(
1845            entry.symbols.is_empty(),
1846            "expected empty symbols for wildcard, got {:?}",
1847            entry.symbols
1848        );
1849    }
1850
1851    // -----------------------------------------------------------------------
1852    // PY-BARREL-06: `from .module import Foo, Bar` extracts named (wildcard=false)
1853    // -----------------------------------------------------------------------
1854    #[test]
1855    fn py_barrel_06_re_export_named_multi_symbol() {
1856        // Given: __init__.py content with multiple named re-exports
1857        let source = "from .module import Foo, Bar\n";
1858
1859        // When: extract_barrel_re_exports is called
1860        let extractor = PythonExtractor::new();
1861        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1862
1863        // Then: one entry with wildcard=false, symbols=["Foo", "Bar"]
1864        let entry = result.iter().find(|e| e.from_specifier == "./module");
1865        assert!(entry.is_some(), "./module not found in {:?}", result);
1866        let entry = entry.unwrap();
1867        assert!(
1868            !entry.wildcard,
1869            "expected wildcard=false for named re-export, got {:?}",
1870            entry
1871        );
1872        assert!(
1873            entry.symbols.contains(&"Foo".to_string()),
1874            "Foo not in symbols: {:?}",
1875            entry.symbols
1876        );
1877        assert!(
1878            entry.symbols.contains(&"Bar".to_string()),
1879            "Bar not in symbols: {:?}",
1880            entry.symbols
1881        );
1882    }
1883
1884    // -----------------------------------------------------------------------
1885    // PY-BARREL-07: e2e: wildcard barrel resolves imported symbol
1886    // test imports `from pkg import Foo`, pkg/__init__.py has `from .module import *`,
1887    // pkg/module.py defines Foo → mapped
1888    // -----------------------------------------------------------------------
1889    #[test]
1890    fn py_barrel_07_e2e_wildcard_barrel_mapped() {
1891        use tempfile::TempDir;
1892
1893        let dir = TempDir::new().unwrap();
1894        let pkg = dir.path().join("pkg");
1895        std::fs::create_dir_all(&pkg).unwrap();
1896
1897        // pkg/__init__.py: wildcard re-export
1898        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1899        // pkg/module.py: defines Foo
1900        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
1901        // tests/test_foo.py: imports from pkg
1902        let tests_dir = dir.path().join("tests");
1903        std::fs::create_dir_all(&tests_dir).unwrap();
1904        std::fs::write(
1905            tests_dir.join("test_foo.py"),
1906            "from pkg import Foo\n\ndef test_foo():\n    assert Foo()\n",
1907        )
1908        .unwrap();
1909
1910        let extractor = PythonExtractor::new();
1911        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1912        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1913        let test_source = std::fs::read_to_string(&test_path).unwrap();
1914
1915        let production_files = vec![module_path.clone()];
1916        let test_sources: HashMap<String, String> =
1917            [(test_path.clone(), test_source)].into_iter().collect();
1918
1919        // When: map_test_files_with_imports
1920        let result = extractor.map_test_files_with_imports(
1921            &production_files,
1922            &test_sources,
1923            dir.path(),
1924            false,
1925        );
1926
1927        // Then: module.py is matched to test_foo.py via barrel chain
1928        let mapping = result.iter().find(|m| m.production_file == module_path);
1929        assert!(
1930            mapping.is_some(),
1931            "module.py not found in mappings: {:?}",
1932            result
1933        );
1934        let mapping = mapping.unwrap();
1935        assert!(
1936            mapping.test_files.contains(&test_path),
1937            "test_foo.py not matched to module.py: {:?}",
1938            mapping.test_files
1939        );
1940    }
1941
1942    // -----------------------------------------------------------------------
1943    // PY-BARREL-08: e2e: named barrel resolves imported symbol
1944    // test imports `from pkg import Foo`, pkg/__init__.py has `from .module import Foo`,
1945    // pkg/module.py defines Foo → mapped
1946    // -----------------------------------------------------------------------
1947    #[test]
1948    fn py_barrel_08_e2e_named_barrel_mapped() {
1949        use tempfile::TempDir;
1950
1951        let dir = TempDir::new().unwrap();
1952        let pkg = dir.path().join("pkg");
1953        std::fs::create_dir_all(&pkg).unwrap();
1954
1955        // pkg/__init__.py: named re-export
1956        std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
1957        // pkg/module.py: defines Foo
1958        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
1959        // tests/test_foo.py: imports from pkg
1960        let tests_dir = dir.path().join("tests");
1961        std::fs::create_dir_all(&tests_dir).unwrap();
1962        std::fs::write(
1963            tests_dir.join("test_foo.py"),
1964            "from pkg import Foo\n\ndef test_foo():\n    assert Foo()\n",
1965        )
1966        .unwrap();
1967
1968        let extractor = PythonExtractor::new();
1969        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1970        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1971        let test_source = std::fs::read_to_string(&test_path).unwrap();
1972
1973        let production_files = vec![module_path.clone()];
1974        let test_sources: HashMap<String, String> =
1975            [(test_path.clone(), test_source)].into_iter().collect();
1976
1977        // When: map_test_files_with_imports
1978        let result = extractor.map_test_files_with_imports(
1979            &production_files,
1980            &test_sources,
1981            dir.path(),
1982            false,
1983        );
1984
1985        // Then: module.py is matched to test_foo.py via barrel chain
1986        let mapping = result.iter().find(|m| m.production_file == module_path);
1987        assert!(
1988            mapping.is_some(),
1989            "module.py not found in mappings: {:?}",
1990            result
1991        );
1992        let mapping = mapping.unwrap();
1993        assert!(
1994            mapping.test_files.contains(&test_path),
1995            "test_foo.py not matched to module.py: {:?}",
1996            mapping.test_files
1997        );
1998    }
1999
2000    // -----------------------------------------------------------------------
2001    // PY-BARREL-09: e2e: wildcard barrel does NOT map non-exported symbol
2002    // test imports `from pkg import NonExistent`, pkg/__init__.py has `from .module import *`,
2003    // pkg/module.py has __all__ = ["Foo"] (does NOT export NonExistent) → NOT mapped
2004    // -----------------------------------------------------------------------
2005    #[test]
2006    fn py_barrel_09_e2e_wildcard_barrel_non_exported_not_mapped() {
2007        use tempfile::TempDir;
2008
2009        let dir = TempDir::new().unwrap();
2010        let pkg = dir.path().join("pkg");
2011        std::fs::create_dir_all(&pkg).unwrap();
2012
2013        // pkg/__init__.py: wildcard re-export
2014        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
2015        // pkg/module.py: __all__ explicitly limits exports to Foo only
2016        std::fs::write(
2017            pkg.join("module.py"),
2018            "__all__ = [\"Foo\"]\n\nclass Foo:\n    pass\n\nclass NonExistent:\n    pass\n",
2019        )
2020        .unwrap();
2021        // tests/test_nonexistent.py: imports NonExistent from pkg
2022        let tests_dir = dir.path().join("tests");
2023        std::fs::create_dir_all(&tests_dir).unwrap();
2024        std::fs::write(
2025            tests_dir.join("test_nonexistent.py"),
2026            "from pkg import NonExistent\n\ndef test_ne():\n    assert NonExistent()\n",
2027        )
2028        .unwrap();
2029
2030        let extractor = PythonExtractor::new();
2031        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
2032        let test_path = tests_dir
2033            .join("test_nonexistent.py")
2034            .to_string_lossy()
2035            .into_owned();
2036        let test_source = std::fs::read_to_string(&test_path).unwrap();
2037
2038        let production_files = vec![module_path.clone()];
2039        let test_sources: HashMap<String, String> =
2040            [(test_path.clone(), test_source)].into_iter().collect();
2041
2042        // When: map_test_files_with_imports
2043        let result = extractor.map_test_files_with_imports(
2044            &production_files,
2045            &test_sources,
2046            dir.path(),
2047            false,
2048        );
2049
2050        // Then: module.py is NOT matched to test_nonexistent.py
2051        // (NonExistent is not exported by module.py)
2052        let mapping = result.iter().find(|m| m.production_file == module_path);
2053        if let Some(mapping) = mapping {
2054            assert!(
2055                !mapping.test_files.contains(&test_path),
2056                "test_nonexistent.py should NOT be matched to module.py: {:?}",
2057                mapping.test_files
2058            );
2059        }
2060        // If no mapping found for module.py at all, that's also correct
2061    }
2062
2063    // -----------------------------------------------------------------------
2064    // PY-E2E-01: models.py + test_models.py (same dir) -> Layer 1 match
2065    // -----------------------------------------------------------------------
2066    #[test]
2067    fn py_e2e_01_layer1_stem_match() {
2068        // Given: production file models.py and test file test_models.py in the same directory
2069        let extractor = PythonExtractor::new();
2070        let production_files = vec!["e2e_pkg/models.py".to_string()];
2071        let test_sources: HashMap<String, String> =
2072            [("e2e_pkg/test_models.py".to_string(), "".to_string())]
2073                .into_iter()
2074                .collect();
2075
2076        // When: map_test_files_with_imports is called
2077        let scan_root = PathBuf::from(".");
2078        let result = extractor.map_test_files_with_imports(
2079            &production_files,
2080            &test_sources,
2081            &scan_root,
2082            false,
2083        );
2084
2085        // Then: models.py is matched to test_models.py via Layer 1 (FileNameConvention)
2086        let mapping = result
2087            .iter()
2088            .find(|m| m.production_file == "e2e_pkg/models.py");
2089        assert!(
2090            mapping.is_some(),
2091            "models.py not found in mappings: {:?}",
2092            result
2093        );
2094        let mapping = mapping.unwrap();
2095        assert!(
2096            mapping
2097                .test_files
2098                .contains(&"e2e_pkg/test_models.py".to_string()),
2099            "test_models.py not in test_files: {:?}",
2100            mapping.test_files
2101        );
2102        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2103    }
2104
2105    // -----------------------------------------------------------------------
2106    // PY-E2E-02: views.py + test importing `from ..views import index` -> Layer 2 match
2107    // -----------------------------------------------------------------------
2108    #[test]
2109    fn py_e2e_02_layer2_import_tracing() {
2110        // Given: production file views.py and a test that imports from it
2111        let extractor = PythonExtractor::new();
2112
2113        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2114            .parent()
2115            .unwrap()
2116            .parent()
2117            .unwrap()
2118            .join("tests/fixtures/python/observe/e2e_pkg");
2119
2120        let views_path = fixture_root.join("views.py").to_string_lossy().into_owned();
2121        let test_views_path = fixture_root
2122            .join("tests/test_views.py")
2123            .to_string_lossy()
2124            .into_owned();
2125
2126        let test_source =
2127            std::fs::read_to_string(fixture_root.join("tests/test_views.py")).unwrap_or_default();
2128
2129        let production_files = vec![views_path.clone()];
2130        let test_sources: HashMap<String, String> = [(test_views_path.clone(), test_source)]
2131            .into_iter()
2132            .collect();
2133
2134        // When: map_test_files_with_imports is called
2135        let result = extractor.map_test_files_with_imports(
2136            &production_files,
2137            &test_sources,
2138            &fixture_root,
2139            false,
2140        );
2141
2142        // Then: views.py is matched to test_views.py (Layer 2 or Layer 1)
2143        let mapping = result.iter().find(|m| m.production_file == views_path);
2144        assert!(
2145            mapping.is_some(),
2146            "views.py not found in mappings: {:?}",
2147            result
2148        );
2149        let mapping = mapping.unwrap();
2150        assert!(
2151            mapping.test_files.contains(&test_views_path),
2152            "test_views.py not matched to views.py: {:?}",
2153            mapping.test_files
2154        );
2155    }
2156
2157    // -----------------------------------------------------------------------
2158    // PY-E2E-03: conftest.py is excluded from mapping as helper
2159    // -----------------------------------------------------------------------
2160    #[test]
2161    fn py_e2e_03_conftest_excluded_as_helper() {
2162        // Given: conftest.py alongside test files
2163        let extractor = PythonExtractor::new();
2164        let production_files = vec!["e2e_pkg/models.py".to_string()];
2165        let test_sources: HashMap<String, String> = [
2166            ("e2e_pkg/tests/test_models.py".to_string(), "".to_string()),
2167            (
2168                "e2e_pkg/tests/conftest.py".to_string(),
2169                "import pytest\n".to_string(),
2170            ),
2171        ]
2172        .into_iter()
2173        .collect();
2174
2175        // When: map_test_files_with_imports is called
2176        let scan_root = PathBuf::from(".");
2177        let result = extractor.map_test_files_with_imports(
2178            &production_files,
2179            &test_sources,
2180            &scan_root,
2181            false,
2182        );
2183
2184        // Then: conftest.py is NOT included in any test_files list
2185        for mapping in &result {
2186            assert!(
2187                !mapping.test_files.iter().any(|f| f.contains("conftest.py")),
2188                "conftest.py should not appear in mappings: {:?}",
2189                mapping
2190            );
2191        }
2192    }
2193
2194    // -----------------------------------------------------------------------
2195    // Helper: setup tempdir with files and run map_test_files_with_imports
2196    // -----------------------------------------------------------------------
2197
2198    struct ImportTestResult {
2199        mappings: Vec<FileMapping>,
2200        prod_path: String,
2201        test_path: String,
2202        _tmp: tempfile::TempDir,
2203    }
2204
2205    /// Create a tempdir with one production file and one test file, then run
2206    /// `map_test_files_with_imports`. `extra_files` are written but not included
2207    /// in `production_files` or `test_sources` (e.g. `__init__.py`).
2208    fn run_import_test(
2209        prod_rel: &str,
2210        prod_content: &str,
2211        test_rel: &str,
2212        test_content: &str,
2213        extra_files: &[(&str, &str)],
2214    ) -> ImportTestResult {
2215        let tmp = tempfile::tempdir().unwrap();
2216
2217        // Write extra files first (e.g. __init__.py)
2218        for (rel, content) in extra_files {
2219            let path = tmp.path().join(rel);
2220            if let Some(parent) = path.parent() {
2221                std::fs::create_dir_all(parent).unwrap();
2222            }
2223            std::fs::write(&path, content).unwrap();
2224        }
2225
2226        // Write production file
2227        let prod_abs = tmp.path().join(prod_rel);
2228        if let Some(parent) = prod_abs.parent() {
2229            std::fs::create_dir_all(parent).unwrap();
2230        }
2231        std::fs::write(&prod_abs, prod_content).unwrap();
2232
2233        // Write test file
2234        let test_abs = tmp.path().join(test_rel);
2235        if let Some(parent) = test_abs.parent() {
2236            std::fs::create_dir_all(parent).unwrap();
2237        }
2238        std::fs::write(&test_abs, test_content).unwrap();
2239
2240        let extractor = PythonExtractor::new();
2241        let prod_path = prod_abs.to_string_lossy().into_owned();
2242        let test_path = test_abs.to_string_lossy().into_owned();
2243        let production_files = vec![prod_path.clone()];
2244        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2245            .into_iter()
2246            .collect();
2247
2248        let mappings = extractor.map_test_files_with_imports(
2249            &production_files,
2250            &test_sources,
2251            tmp.path(),
2252            false,
2253        );
2254
2255        ImportTestResult {
2256            mappings,
2257            prod_path,
2258            test_path,
2259            _tmp: tmp,
2260        }
2261    }
2262
2263    // -----------------------------------------------------------------------
2264    // PY-ABS-01: `from models.cars import Car` -> mapped to models/cars.py via Layer 2
2265    // -----------------------------------------------------------------------
2266    #[test]
2267    fn py_abs_01_absolute_import_nested_module() {
2268        // Given: `from models.cars import Car` in tests/unit/test_car.py,
2269        //        models/cars.py exists at scan_root
2270        let r = run_import_test(
2271            "models/cars.py",
2272            "class Car:\n    pass\n",
2273            "tests/unit/test_car.py",
2274            "from models.cars import Car\n\ndef test_car():\n    pass\n",
2275            &[],
2276        );
2277
2278        // Then: models/cars.py is mapped to test_car.py via Layer 2 (ImportTracing)
2279        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2280        assert!(
2281            mapping.is_some(),
2282            "models/cars.py not found in mappings: {:?}",
2283            r.mappings
2284        );
2285        let mapping = mapping.unwrap();
2286        assert!(
2287            mapping.test_files.contains(&r.test_path),
2288            "test_car.py not in test_files for models/cars.py: {:?}",
2289            mapping.test_files
2290        );
2291        assert_eq!(
2292            mapping.strategy,
2293            MappingStrategy::ImportTracing,
2294            "expected ImportTracing strategy, got {:?}",
2295            mapping.strategy
2296        );
2297    }
2298
2299    // -----------------------------------------------------------------------
2300    // PY-ABS-02: `from utils.publish_state import ...` -> mapped to utils/publish_state.py
2301    // -----------------------------------------------------------------------
2302    #[test]
2303    fn py_abs_02_absolute_import_utils_module() {
2304        // Given: `from utils.publish_state import PublishState` in tests/test_pub.py,
2305        //        utils/publish_state.py exists at scan_root
2306        let r = run_import_test(
2307            "utils/publish_state.py",
2308            "class PublishState:\n    pass\n",
2309            "tests/test_pub.py",
2310            "from utils.publish_state import PublishState\n\ndef test_pub():\n    pass\n",
2311            &[],
2312        );
2313
2314        // Then: utils/publish_state.py is mapped to test_pub.py via Layer 2
2315        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2316        assert!(
2317            mapping.is_some(),
2318            "utils/publish_state.py not found in mappings: {:?}",
2319            r.mappings
2320        );
2321        let mapping = mapping.unwrap();
2322        assert!(
2323            mapping.test_files.contains(&r.test_path),
2324            "test_pub.py not in test_files for utils/publish_state.py: {:?}",
2325            mapping.test_files
2326        );
2327        assert_eq!(
2328            mapping.strategy,
2329            MappingStrategy::ImportTracing,
2330            "expected ImportTracing strategy, got {:?}",
2331            mapping.strategy
2332        );
2333    }
2334
2335    // -----------------------------------------------------------------------
2336    // PY-ABS-03: relative import `from .models import X` -> resolves from from_file parent
2337    // -----------------------------------------------------------------------
2338    #[test]
2339    fn py_abs_03_relative_import_still_resolves() {
2340        // Given: `from .models import X` in pkg/test_something.py,
2341        //        pkg/models.py exists relative to test file
2342        // Note: production file must NOT be inside tests/ dir (Phase 20: tests/ files are helpers)
2343        let r = run_import_test(
2344            "pkg/models.py",
2345            "class X:\n    pass\n",
2346            "pkg/test_something.py",
2347            "from .models import X\n\ndef test_x():\n    pass\n",
2348            &[],
2349        );
2350
2351        // Then: models.py is mapped to test_something.py (relative import resolves from parent dir)
2352        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2353        assert!(
2354            mapping.is_some(),
2355            "pkg/models.py not found in mappings: {:?}",
2356            r.mappings
2357        );
2358        let mapping = mapping.unwrap();
2359        assert!(
2360            mapping.test_files.contains(&r.test_path),
2361            "test_something.py not in test_files for pkg/models.py: {:?}",
2362            mapping.test_files
2363        );
2364    }
2365
2366    // -----------------------------------------------------------------------
2367    // PY-STEM-07: _decoders.py -> production_stem strips single leading underscore
2368    // -----------------------------------------------------------------------
2369    #[test]
2370    fn py_stem_07_production_stem_single_underscore_prefix() {
2371        // Given: production file path "httpx/_decoders.py"
2372        // When: production_stem() is called
2373        // Then: returns Some("decoders") (single leading underscore stripped)
2374        let extractor = PythonExtractor::new();
2375        let result = extractor.production_stem("httpx/_decoders.py");
2376        assert_eq!(result, Some("decoders"));
2377    }
2378
2379    // -----------------------------------------------------------------------
2380    // PY-STEM-08: __version__.py -> production_stem strips only one underscore
2381    // -----------------------------------------------------------------------
2382    #[test]
2383    fn py_stem_08_production_stem_double_underscore_strips_one() {
2384        // Given: production file path "httpx/__version__.py"
2385        // When: production_stem() is called
2386        // Then: returns Some("_version") (only one leading underscore stripped, not __init__ which returns None)
2387        let extractor = PythonExtractor::new();
2388        let result = extractor.production_stem("httpx/__version__.py");
2389        assert_eq!(result, Some("_version"));
2390    }
2391
2392    // -----------------------------------------------------------------------
2393    // PY-STEM-09: decoders.py -> production_stem unchanged (regression)
2394    // -----------------------------------------------------------------------
2395    #[test]
2396    fn py_stem_09_production_stem_no_prefix_regression() {
2397        // Given: production file path "httpx/decoders.py" (no underscore prefix)
2398        // When: production_stem() is called
2399        // Then: returns Some("decoders") (unchanged, no regression)
2400        let extractor = PythonExtractor::new();
2401        let result = extractor.production_stem("httpx/decoders.py");
2402        assert_eq!(result, Some("decoders"));
2403    }
2404
2405    // -----------------------------------------------------------------------
2406    // PY-STEM-10: ___triple.py -> production_stem strips one underscore
2407    // -----------------------------------------------------------------------
2408    #[test]
2409    fn py_stem_10_production_stem_triple_underscore() {
2410        // Given: production file path "pkg/___triple.py"
2411        // When: production_stem() is called
2412        // Then: returns Some("__triple") (one leading underscore stripped)
2413        let extractor = PythonExtractor::new();
2414        let result = extractor.production_stem("pkg/___triple.py");
2415        assert_eq!(result, Some("__triple"));
2416    }
2417
2418    // -----------------------------------------------------------------------
2419    // PY-STEM-11: ___foo__.py -> strip_prefix + strip_suffix chained
2420    // -----------------------------------------------------------------------
2421    #[test]
2422    fn py_stem_11_production_stem_prefix_and_suffix_chained() {
2423        // Given: production file path "pkg/___foo__.py"
2424        // When: production_stem() is called
2425        // Then: returns Some("__foo") (strip_prefix('_') -> "__foo__", strip_suffix("__") -> "__foo")
2426        let extractor = PythonExtractor::new();
2427        let result = extractor.production_stem("pkg/___foo__.py");
2428        assert_eq!(result, Some("__foo"));
2429    }
2430
2431    // -----------------------------------------------------------------------
2432    // PY-STEM-12: __foo__.py -> strip_prefix + strip_suffix (double underscore prefix)
2433    // -----------------------------------------------------------------------
2434    #[test]
2435    fn py_stem_12_production_stem_dunder_prefix_and_suffix() {
2436        // Given: production file path "pkg/__foo__.py"
2437        // When: production_stem() is called
2438        // Then: returns Some("_foo") (strip_prefix('_') -> "_foo__", strip_suffix("__") -> "_foo")
2439        let extractor = PythonExtractor::new();
2440        let result = extractor.production_stem("pkg/__foo__.py");
2441        assert_eq!(result, Some("_foo"));
2442    }
2443
2444    // -----------------------------------------------------------------------
2445    // PY-STEM-13: test_stem("app/tests.py") -> Some("app")
2446    // -----------------------------------------------------------------------
2447    #[test]
2448    fn py_stem_13_tests_file_with_parent_dir() {
2449        // Given: path = "app/tests.py"
2450        // When: test_stem(path)
2451        // Then: Some("app") (parent directory name used as stem)
2452        let result = test_stem("app/tests.py");
2453        assert_eq!(result, Some("app"));
2454    }
2455
2456    // -----------------------------------------------------------------------
2457    // PY-STEM-14: test_stem("tests/aggregation/tests.py") -> Some("aggregation")
2458    // -----------------------------------------------------------------------
2459    #[test]
2460    fn py_stem_14_tests_file_with_nested_parent_dir() {
2461        // Given: path = "tests/aggregation/tests.py"
2462        // When: test_stem(path)
2463        // Then: Some("aggregation") (immediate parent directory name used as stem)
2464        let result = test_stem("tests/aggregation/tests.py");
2465        assert_eq!(result, Some("aggregation"));
2466    }
2467
2468    // -----------------------------------------------------------------------
2469    // PY-STEM-15: test_stem("tests.py") -> None (no parent dir)
2470    // -----------------------------------------------------------------------
2471    #[test]
2472    fn py_stem_15_tests_file_no_parent_dir() {
2473        // Given: path = "tests.py" (no parent directory component)
2474        // When: test_stem(path)
2475        // Then: None (no parent dir to derive stem from, defer to Layer 2)
2476        let result = test_stem("tests.py");
2477        assert_eq!(result, None);
2478    }
2479
2480    // -----------------------------------------------------------------------
2481    // PY-STEM-16: production_stem("app/tests.py") -> None
2482    // -----------------------------------------------------------------------
2483    #[test]
2484    fn py_stem_16_production_stem_excludes_tests_file() {
2485        // Given: path = "app/tests.py"
2486        // When: production_stem(path)
2487        // Then: None (tests.py must not appear in production_files)
2488        let result = production_stem("app/tests.py");
2489        assert_eq!(result, None);
2490    }
2491
2492    // -----------------------------------------------------------------------
2493    // PY-SRCLAYOUT-01: src/ layout absolute import resolved
2494    // -----------------------------------------------------------------------
2495    #[test]
2496    fn py_srclayout_01_src_layout_absolute_import_resolved() {
2497        // Given: tempdir with "src/mypackage/__init__.py" + "src/mypackage/sessions.py"
2498        //        and test file "tests/test_sessions.py" containing "from mypackage.sessions import Session"
2499        let r = run_import_test(
2500            "src/mypackage/sessions.py",
2501            "class Session:\n    pass\n",
2502            "tests/test_sessions.py",
2503            "from mypackage.sessions import Session\n\ndef test_session():\n    pass\n",
2504            &[("src/mypackage/__init__.py", "")],
2505        );
2506
2507        // Then: sessions.py is in test_files for test_sessions.py.
2508        // Layer 1 core does not match because prod dir (src/mypackage) != test dir (tests),
2509        // but stem-only fallback matches via stem "sessions" (cross-directory).
2510        // Strategy remains FileNameConvention (L1 fallback is still L1).
2511        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2512        assert!(
2513            mapping.is_some(),
2514            "src/mypackage/sessions.py not found in mappings: {:?}",
2515            r.mappings
2516        );
2517        let mapping = mapping.unwrap();
2518        assert!(
2519            mapping.test_files.contains(&r.test_path),
2520            "test_sessions.py not in test_files for sessions.py (src/ layout): {:?}",
2521            mapping.test_files
2522        );
2523        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2524    }
2525
2526    // -----------------------------------------------------------------------
2527    // PY-SRCLAYOUT-02: non-src layout still works (regression)
2528    // -----------------------------------------------------------------------
2529    #[test]
2530    fn py_srclayout_02_non_src_layout_regression() {
2531        // Given: tempdir with "mypackage/sessions.py"
2532        //        and test file "tests/test_sessions.py" containing "from mypackage.sessions import Session"
2533        let r = run_import_test(
2534            "mypackage/sessions.py",
2535            "class Session:\n    pass\n",
2536            "tests/test_sessions.py",
2537            "from mypackage.sessions import Session\n\ndef test_session():\n    pass\n",
2538            &[],
2539        );
2540
2541        // Then: sessions.py is in test_files for test_sessions.py (non-src layout still works).
2542        // Layer 1 core does not match because prod dir (mypackage) != test dir (tests),
2543        // but stem-only fallback matches via stem "sessions" (cross-directory).
2544        // Strategy remains FileNameConvention (L1 fallback is still L1).
2545        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2546        assert!(
2547            mapping.is_some(),
2548            "mypackage/sessions.py not found in mappings: {:?}",
2549            r.mappings
2550        );
2551        let mapping = mapping.unwrap();
2552        assert!(
2553            mapping.test_files.contains(&r.test_path),
2554            "test_sessions.py not in test_files for sessions.py (non-src layout): {:?}",
2555            mapping.test_files
2556        );
2557        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2558    }
2559
2560    // -----------------------------------------------------------------------
2561    // PY-ABS-04: `from nonexistent.module import X` -> no mapping added (graceful skip)
2562    // -----------------------------------------------------------------------
2563    #[test]
2564    fn py_abs_04_nonexistent_absolute_import_skipped() {
2565        // Given: `from nonexistent.module import X` in test file,
2566        //        nonexistent/module.py does NOT exist at scan_root.
2567        //        models/real.py exists as production file but is NOT imported.
2568        let r = run_import_test(
2569            "models/real.py",
2570            "class Real:\n    pass\n",
2571            "tests/test_missing.py",
2572            "from nonexistent.module import X\n\ndef test_x():\n    pass\n",
2573            &[],
2574        );
2575
2576        // Then: test_missing.py is NOT mapped to models/real.py (unresolvable import skipped)
2577        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2578        if let Some(mapping) = mapping {
2579            assert!(
2580                !mapping.test_files.contains(&r.test_path),
2581                "test_missing.py should NOT be mapped to models/real.py: {:?}",
2582                mapping.test_files
2583            );
2584        }
2585        // passing if no mapping or test_path not in mapping
2586    }
2587
2588    // -----------------------------------------------------------------------
2589    // PY-ABS-05: absolute import in test file maps to production file outside tests/
2590    // -----------------------------------------------------------------------
2591    #[test]
2592    fn py_abs_05_mixed_absolute_and_relative_imports() {
2593        // Given: a test file with `from models.cars import Car` (absolute),
2594        //        models/cars.py exists at scan_root,
2595        //        tests/helpers.py also exists but is a test helper (Phase 20: excluded)
2596        let tmp = tempfile::tempdir().unwrap();
2597        let models_dir = tmp.path().join("models");
2598        let tests_dir = tmp.path().join("tests");
2599        std::fs::create_dir_all(&models_dir).unwrap();
2600        std::fs::create_dir_all(&tests_dir).unwrap();
2601
2602        let cars_py = models_dir.join("cars.py");
2603        std::fs::write(&cars_py, "class Car:\n    pass\n").unwrap();
2604
2605        let helpers_py = tests_dir.join("helpers.py");
2606        std::fs::write(&helpers_py, "def setup():\n    pass\n").unwrap();
2607
2608        let test_py = tests_dir.join("test_mixed.py");
2609        let test_source =
2610            "from models.cars import Car\nfrom .helpers import setup\n\ndef test_mixed():\n    pass\n";
2611        std::fs::write(&test_py, test_source).unwrap();
2612
2613        let extractor = PythonExtractor::new();
2614        let cars_prod = cars_py.to_string_lossy().into_owned();
2615        let helpers_prod = helpers_py.to_string_lossy().into_owned();
2616        let test_path = test_py.to_string_lossy().into_owned();
2617
2618        let production_files = vec![cars_prod.clone(), helpers_prod.clone()];
2619        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2620            .into_iter()
2621            .collect();
2622
2623        // When: map_test_files_with_imports is called
2624        let result = extractor.map_test_files_with_imports(
2625            &production_files,
2626            &test_sources,
2627            tmp.path(),
2628            false,
2629        );
2630
2631        // Then: models/cars.py is mapped via absolute import (Layer 2)
2632        let cars_mapping = result.iter().find(|m| m.production_file == cars_prod);
2633        assert!(
2634            cars_mapping.is_some(),
2635            "models/cars.py not found in mappings: {:?}",
2636            result
2637        );
2638        let cars_m = cars_mapping.unwrap();
2639        assert!(
2640            cars_m.test_files.contains(&test_path),
2641            "test_mixed.py not mapped to models/cars.py via absolute import: {:?}",
2642            cars_m.test_files
2643        );
2644
2645        // Then: tests/helpers.py should NOT appear in mappings (Phase 20: tests/ dir files are helpers)
2646        let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
2647        assert!(
2648            helpers_mapping.is_none(),
2649            "tests/helpers.py should be excluded as test helper (Phase 20), but found in mappings: {:?}",
2650            helpers_mapping
2651        );
2652    }
2653
2654    // -----------------------------------------------------------------------
2655    // PY-REL-01: `from .. import X` bare two-dot relative import
2656    // -----------------------------------------------------------------------
2657    #[test]
2658    fn py_rel_01_bare_two_dot_relative_import() {
2659        // Given: `from .. import utils` in pkg/sub/test_thing.py,
2660        //        pkg/utils.py exists (parent of parent)
2661        let r = run_import_test(
2662            "pkg/utils.py",
2663            "def helper():\n    pass\n",
2664            "pkg/sub/test_thing.py",
2665            "from .. import utils\n\ndef test_thing():\n    pass\n",
2666            &[],
2667        );
2668
2669        // Then: pkg/utils.py is mapped via bare relative import (is_bare_relative=true path)
2670        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2671        assert!(
2672            mapping.is_some(),
2673            "pkg/utils.py not found in mappings: {:?}",
2674            r.mappings
2675        );
2676        let mapping = mapping.unwrap();
2677        assert!(
2678            mapping.test_files.contains(&r.test_path),
2679            "test_thing.py not in test_files for pkg/utils.py via bare two-dot import: {:?}",
2680            mapping.test_files
2681        );
2682    }
2683
2684    // -----------------------------------------------------------------------
2685    // PY-L2-DJANGO-01: Django layout tests.py mapped via Layer 2
2686    // -----------------------------------------------------------------------
2687    #[test]
2688    fn py_l2_django_01_tests_file_mapped_via_import_tracing() {
2689        // Given: tempdir with src/models.py (production) and app/tests.py (test)
2690        //        app/tests.py contains `from src.models import Model`
2691        let r = run_import_test(
2692            "src/models.py",
2693            "class Model:\n    pass\n",
2694            "app/tests.py",
2695            "from src.models import Model\n\n\ndef test_model():\n    pass\n",
2696            &[],
2697        );
2698
2699        // Then: src/models.py is mapped to app/tests.py via ImportTracing strategy
2700        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2701        assert!(
2702            mapping.is_some(),
2703            "src/models.py not found in mappings: {:?}",
2704            r.mappings
2705        );
2706        let mapping = mapping.unwrap();
2707        assert!(
2708            mapping.test_files.contains(&r.test_path),
2709            "app/tests.py not in test_files for src/models.py: {:?}",
2710            mapping.test_files
2711        );
2712        assert_eq!(
2713            mapping.strategy,
2714            MappingStrategy::ImportTracing,
2715            "expected ImportTracing strategy, got {:?}",
2716            mapping.strategy
2717        );
2718    }
2719
2720    // -----------------------------------------------------------------------
2721    // TC-01: Django project with project/manage.py and from app.models import X
2722    //        -> test maps to project/app/models.py via manage.py root fallback
2723    // -----------------------------------------------------------------------
2724    #[test]
2725    fn py_l2_django_managepy_root_tc01_subdirectory_layout() {
2726        // Given: Django project with project/manage.py, project/app/models.py
2727        //        and a test file with `from app.models import MyModel`
2728        let tmp = tempfile::tempdir().unwrap();
2729
2730        let manage_py_path = tmp.path().join("project").join("manage.py");
2731        std::fs::create_dir_all(manage_py_path.parent().unwrap()).unwrap();
2732        std::fs::write(&manage_py_path, "#!/usr/bin/env python\n").unwrap();
2733
2734        let prod_rel = "project/app/models.py";
2735        let prod_abs = tmp.path().join(prod_rel);
2736        std::fs::create_dir_all(prod_abs.parent().unwrap()).unwrap();
2737        std::fs::write(&prod_abs, "class MyModel:\n    pass\n").unwrap();
2738
2739        let test_rel = "tests/test_models.py";
2740        let test_abs = tmp.path().join(test_rel);
2741        std::fs::create_dir_all(test_abs.parent().unwrap()).unwrap();
2742        let test_content = "from app.models import MyModel\n\ndef test_mymodel():\n    pass\n";
2743        std::fs::write(&test_abs, test_content).unwrap();
2744
2745        let extractor = PythonExtractor::new();
2746        let prod_path = prod_abs.to_string_lossy().into_owned();
2747        let test_path = test_abs.to_string_lossy().into_owned();
2748        let production_files = vec![prod_path.clone()];
2749        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2750            .into_iter()
2751            .collect();
2752
2753        // When: observe runs
2754        let mappings = extractor.map_test_files_with_imports(
2755            &production_files,
2756            &test_sources,
2757            tmp.path(),
2758            false,
2759        );
2760
2761        // Then: test maps to project/app/models.py via manage.py root fallback
2762        let mapping = mappings.iter().find(|m| m.production_file == prod_path);
2763        assert!(
2764            mapping.is_some(),
2765            "project/app/models.py not found in mappings: {:?}",
2766            mappings
2767        );
2768        let mapping = mapping.unwrap();
2769        assert!(
2770            mapping.test_files.contains(&test_path),
2771            "tests/test_models.py not in test_files for project/app/models.py: {:?}",
2772            mapping.test_files
2773        );
2774        assert_eq!(
2775            mapping.strategy,
2776            MappingStrategy::ImportTracing,
2777            "expected ImportTracing strategy, got {:?}",
2778            mapping.strategy
2779        );
2780    }
2781
2782    // -----------------------------------------------------------------------
2783    // TC-03: find_manage_py_root returns None when manage.py is at scan_root itself
2784    // -----------------------------------------------------------------------
2785    #[test]
2786    fn py_l2_django_managepy_root_tc03_at_scan_root_returns_none() {
2787        // Given: manage.py at scan_root itself (not in a subdirectory)
2788        let tmp = tempfile::tempdir().unwrap();
2789        std::fs::write(tmp.path().join("manage.py"), "#!/usr/bin/env python\n").unwrap();
2790
2791        // When: find_manage_py_root is called with scan_root
2792        let result = find_manage_py_root(tmp.path());
2793
2794        // Then: returns None (manage.py at scan_root is already covered by canonical_root)
2795        assert!(
2796            result.is_none(),
2797            "expected None when manage.py is at scan_root, got {:?}",
2798            result
2799        );
2800    }
2801}
2802
2803// ---------------------------------------------------------------------------
2804// Route extraction
2805// ---------------------------------------------------------------------------
2806
2807const ROUTE_DECORATOR_QUERY: &str = include_str!("../queries/route_decorator.scm");
2808static ROUTE_DECORATOR_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2809
2810const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
2811
2812/// A route extracted from a FastAPI application.
2813#[derive(Debug, Clone, PartialEq)]
2814pub struct Route {
2815    pub http_method: String,
2816    pub path: String,
2817    pub handler_name: String,
2818    pub file: String,
2819}
2820
2821/// Extract `router = APIRouter(prefix="...")` assignments from source.
2822/// Returns a HashMap from variable name to prefix string.
2823fn collect_router_prefixes(
2824    source_bytes: &[u8],
2825    tree: &tree_sitter::Tree,
2826) -> HashMap<String, String> {
2827    let mut prefixes = HashMap::new();
2828
2829    // Walk the tree to find: assignment where right side is APIRouter(prefix="...") or Blueprint('name', __name__, url_prefix='...')
2830    let root = tree.root_node();
2831    let mut stack = vec![root];
2832
2833    while let Some(node) = stack.pop() {
2834        if node.kind() == "assignment" {
2835            let left = node.child_by_field_name("left");
2836            let right = node.child_by_field_name("right");
2837
2838            if let (Some(left_node), Some(right_node)) = (left, right) {
2839                if left_node.kind() == "identifier" && right_node.kind() == "call" {
2840                    let var_name = left_node.utf8_text(source_bytes).unwrap_or("").to_string();
2841
2842                    // Check if the call is APIRouter(...) or Blueprint(...)
2843                    let fn_node = right_node.child_by_field_name("function");
2844                    let call_name = fn_node
2845                        .and_then(|f| f.utf8_text(source_bytes).ok())
2846                        .unwrap_or("");
2847                    let is_api_router = call_name == "APIRouter";
2848                    let is_blueprint = call_name == "Blueprint";
2849
2850                    if is_api_router || is_blueprint {
2851                        // For APIRouter, look for `prefix` keyword argument.
2852                        // For Blueprint, look for `url_prefix` keyword argument.
2853                        let prefix_kw = if is_blueprint { "url_prefix" } else { "prefix" };
2854                        let args_node = right_node.child_by_field_name("arguments");
2855                        if let Some(args) = args_node {
2856                            let mut args_cursor = args.walk();
2857                            for arg in args.named_children(&mut args_cursor) {
2858                                if arg.kind() == "keyword_argument" {
2859                                    let kw_name = arg
2860                                        .child_by_field_name("name")
2861                                        .and_then(|n| n.utf8_text(source_bytes).ok())
2862                                        .unwrap_or("");
2863                                    if kw_name == prefix_kw {
2864                                        if let Some(val) = arg.child_by_field_name("value") {
2865                                            if val.kind() == "string" {
2866                                                let raw = val.utf8_text(source_bytes).unwrap_or("");
2867                                                let prefix = strip_string_quotes(raw);
2868                                                prefixes.insert(var_name.clone(), prefix);
2869                                            }
2870                                        }
2871                                    }
2872                                }
2873                            }
2874                        }
2875                        // If no prefix found, insert empty string (APIRouter()/Blueprint() without prefix)
2876                        prefixes.entry(var_name).or_default();
2877                    }
2878                }
2879            }
2880        }
2881
2882        // Push children in reverse order so they are popped in source order (DFS)
2883        let mut w = node.walk();
2884        let children: Vec<_> = node.named_children(&mut w).collect();
2885        for child in children.into_iter().rev() {
2886            stack.push(child);
2887        }
2888    }
2889
2890    prefixes
2891}
2892
2893/// Strip surrounding quotes from a Python string literal.
2894/// `"'/users'"` → `"/users"`, `'"hello"'` → `"hello"`, triple-quoted too.
2895/// Also handles Python string prefixes: r"...", b"...", f"...", u"...", rb"...", etc.
2896///
2897/// Precondition: `raw` must be a tree-sitter `string` node text (always includes quotes after prefix).
2898fn strip_string_quotes(raw: &str) -> String {
2899    // Strip Python string prefix characters (r, b, f, u and combinations thereof).
2900    // Safe because tree-sitter string nodes always have surrounding quotes after the prefix.
2901    let raw = raw.trim_start_matches(|c: char| "rRbBfFuU".contains(c));
2902    // Try triple quotes first
2903    for q in &[r#"""""#, "'''"] {
2904        if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2905            return inner.to_string();
2906        }
2907    }
2908    // Single quotes
2909    for q in &["\"", "'"] {
2910        if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2911            return inner.to_string();
2912        }
2913    }
2914    raw.to_string()
2915}
2916
2917/// Extract HTTP methods from a Flask `methods` keyword argument in a decorator call.
2918///
2919/// Given the `argument_list` node of a decorator call like `@bp.route('/path', methods=['POST'])`,
2920/// looks for a `keyword_argument` with name `"methods"` and parses its list value.
2921/// Returns a vec of uppercase method strings (e.g., `["POST"]`).
2922/// Returns an empty vec if `methods` keyword is not found.
2923fn extract_methods_kwarg<'a>(args_node: tree_sitter::Node<'a>, source_bytes: &[u8]) -> Vec<String> {
2924    let mut cursor = args_node.walk();
2925    for arg in args_node.named_children(&mut cursor) {
2926        if arg.kind() != "keyword_argument" {
2927            continue;
2928        }
2929        let kw_name = arg
2930            .child_by_field_name("name")
2931            .and_then(|n| n.utf8_text(source_bytes).ok())
2932            .unwrap_or("");
2933        if kw_name != "methods" {
2934            continue;
2935        }
2936        // Found `methods=...`; the value should be a list like `['POST', 'GET']`
2937        let val = match arg.child_by_field_name("value") {
2938            Some(v) => v,
2939            None => return vec![],
2940        };
2941        if val.kind() != "list" {
2942            return vec![];
2943        }
2944        let mut methods = Vec::new();
2945        let mut list_cursor = val.walk();
2946        for item in val.named_children(&mut list_cursor) {
2947            if item.kind() == "string" {
2948                let raw = item.utf8_text(source_bytes).unwrap_or("");
2949                let method = strip_string_quotes(raw).to_uppercase();
2950                if !method.is_empty() {
2951                    methods.push(method);
2952                }
2953            }
2954        }
2955        return methods;
2956    }
2957    vec![]
2958}
2959
2960/// Extract FastAPI routes from Python source code.
2961pub fn extract_routes(source: &str, file_path: &str) -> Vec<Route> {
2962    if source.is_empty() {
2963        return Vec::new();
2964    }
2965
2966    let mut parser = PythonExtractor::parser();
2967    let tree = match parser.parse(source, None) {
2968        Some(t) => t,
2969        None => return Vec::new(),
2970    };
2971    let source_bytes = source.as_bytes();
2972
2973    // Pass 1: collect APIRouter prefix assignments
2974    let router_prefixes = collect_router_prefixes(source_bytes, &tree);
2975
2976    // Pass 2: run route_decorator query
2977    let query = cached_query(&ROUTE_DECORATOR_QUERY_CACHE, ROUTE_DECORATOR_QUERY);
2978
2979    let obj_idx = query.capture_index_for_name("route.object");
2980    let method_idx = query.capture_index_for_name("route.method");
2981    let path_idx = query.capture_index_for_name("route.path");
2982    let handler_idx = query.capture_index_for_name("route.handler");
2983
2984    let mut cursor = QueryCursor::new();
2985    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2986
2987    let mut routes = Vec::new();
2988    let mut seen = HashSet::new();
2989
2990    // Pre-compile the colon-to-brace regex for Flask parameter normalization.
2991    // Placed here (outside the match loop) to satisfy clippy::regex_creation_in_loops.
2992    static COLON_PARAM_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
2993    let colon_param_re =
2994        COLON_PARAM_RE.get_or_init(|| regex::Regex::new(r":(\w+)").expect("invalid regex"));
2995
2996    while let Some(m) = matches.next() {
2997        let mut obj: Option<String> = None;
2998        let mut method: Option<String> = None;
2999        let mut path_raw: Option<String> = None;
3000        let mut path_is_string = false;
3001        let mut path_node: Option<tree_sitter::Node> = None;
3002        let mut handler: Option<String> = None;
3003
3004        for cap in m.captures {
3005            let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
3006            if obj_idx == Some(cap.index) {
3007                obj = Some(text);
3008            } else if method_idx == Some(cap.index) {
3009                method = Some(text);
3010            } else if path_idx == Some(cap.index) {
3011                // Determine if it's a string literal or identifier
3012                path_is_string = cap.node.kind() == "string";
3013                path_raw = Some(text);
3014                path_node = Some(cap.node);
3015            } else if handler_idx == Some(cap.index) {
3016                handler = Some(text);
3017            }
3018        }
3019
3020        let (obj, method, handler) = match (obj, method, handler) {
3021            (Some(o), Some(m), Some(h)) => (o, m, h),
3022            _ => continue,
3023        };
3024
3025        // Flask @bp.route('/path', methods=[...]) handling
3026        if method == "route" {
3027            // Resolve path
3028            let sub_path = match path_raw {
3029                Some(ref raw) if path_is_string => strip_string_quotes(raw),
3030                Some(_) => "<dynamic>".to_string(),
3031                None => "<dynamic>".to_string(),
3032            };
3033
3034            // Resolve prefix from router variable (Blueprint url_prefix)
3035            let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
3036            let raw_full_path = if prefix.is_empty() {
3037                sub_path
3038            } else {
3039                format!("{prefix}{sub_path}")
3040            };
3041
3042            // Normalize Flask `<type:param>` / `<param>` syntax to `{param}` style.
3043            // Step 1: `normalize_django_path` converts `<int:id>` → `:id` and `<id>` → `:id`.
3044            // Step 2: convert `:param` segments → `{param}` (matches route_path_to_regex expectations).
3045            let colon_normalized = normalize_django_path(&raw_full_path);
3046            let full_path = colon_param_re
3047                .replace_all(&colon_normalized, "{$1}")
3048                .into_owned();
3049
3050            // Extract `methods` keyword argument from the decorator call's argument_list.
3051            // path_node.parent() is the argument_list node.
3052            let http_methods: Vec<String> =
3053                if let Some(arg_list) = path_node.and_then(|n| n.parent()) {
3054                    let methods_vec = extract_methods_kwarg(arg_list, source_bytes);
3055                    if methods_vec.is_empty() {
3056                        vec!["GET".to_string()]
3057                    } else {
3058                        methods_vec
3059                    }
3060                } else {
3061                    vec!["GET".to_string()]
3062                };
3063
3064            for http_method in http_methods {
3065                let key = (http_method.clone(), full_path.clone(), handler.clone());
3066                if seen.insert(key) {
3067                    routes.push(Route {
3068                        http_method,
3069                        path: full_path.clone(),
3070                        handler_name: handler.clone(),
3071                        file: file_path.to_string(),
3072                    });
3073                }
3074            }
3075            continue;
3076        }
3077
3078        // Filter: method must be a known HTTP method (FastAPI)
3079        if !HTTP_METHODS.contains(&method.as_str()) {
3080            continue;
3081        }
3082
3083        // Resolve path
3084        let sub_path = match path_raw {
3085            Some(ref raw) if path_is_string => strip_string_quotes(raw),
3086            Some(_) => "<dynamic>".to_string(),
3087            None => "<dynamic>".to_string(),
3088        };
3089
3090        // Resolve prefix from router variable
3091        let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
3092        let full_path = if prefix.is_empty() {
3093            sub_path
3094        } else {
3095            format!("{prefix}{sub_path}")
3096        };
3097
3098        // Deduplicate: same (method, path, handler)
3099        let key = (method.clone(), full_path.clone(), handler.clone());
3100        if !seen.insert(key) {
3101            continue;
3102        }
3103
3104        routes.push(Route {
3105            http_method: method.to_uppercase(),
3106            path: full_path,
3107            handler_name: handler,
3108            file: file_path.to_string(),
3109        });
3110    }
3111
3112    routes
3113}
3114
3115// ---------------------------------------------------------------------------
3116// Route extraction tests (FA-RT-01 ~ FA-RT-10)
3117// ---------------------------------------------------------------------------
3118
3119#[cfg(test)]
3120mod route_tests {
3121    use super::*;
3122
3123    // FA-RT-01: basic @app.get route
3124    #[test]
3125    fn fa_rt_01_basic_app_get_route() {
3126        // Given: source with `@app.get("/users") def read_users(): ...`
3127        let source = r#"
3128from fastapi import FastAPI
3129app = FastAPI()
3130
3131@app.get("/users")
3132def read_users():
3133    return []
3134"#;
3135
3136        // When: extract_routes(source, "main.py")
3137        let routes = extract_routes(source, "main.py");
3138
3139        // Then: [Route { method: "GET", path: "/users", handler: "read_users" }]
3140        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3141        assert_eq!(routes[0].http_method, "GET");
3142        assert_eq!(routes[0].path, "/users");
3143        assert_eq!(routes[0].handler_name, "read_users");
3144    }
3145
3146    // FA-RT-02: multiple HTTP methods
3147    #[test]
3148    fn fa_rt_02_multiple_http_methods() {
3149        // Given: source with @app.get, @app.post, @app.put, @app.delete on separate functions
3150        let source = r#"
3151from fastapi import FastAPI
3152app = FastAPI()
3153
3154@app.get("/items")
3155def list_items():
3156    return []
3157
3158@app.post("/items")
3159def create_item():
3160    return {}
3161
3162@app.put("/items/{item_id}")
3163def update_item(item_id: int):
3164    return {}
3165
3166@app.delete("/items/{item_id}")
3167def delete_item(item_id: int):
3168    return {}
3169"#;
3170
3171        // When: extract_routes(source, "main.py")
3172        let routes = extract_routes(source, "main.py");
3173
3174        // Then: 4 routes with correct methods
3175        assert_eq!(routes.len(), 4, "expected 4 routes, got {:?}", routes);
3176        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
3177        assert!(methods.contains(&"GET"), "missing GET");
3178        assert!(methods.contains(&"POST"), "missing POST");
3179        assert!(methods.contains(&"PUT"), "missing PUT");
3180        assert!(methods.contains(&"DELETE"), "missing DELETE");
3181    }
3182
3183    // FA-RT-03: path parameter
3184    #[test]
3185    fn fa_rt_03_path_parameter() {
3186        // Given: `@app.get("/items/{item_id}")`
3187        let source = r#"
3188from fastapi import FastAPI
3189app = FastAPI()
3190
3191@app.get("/items/{item_id}")
3192def read_item(item_id: int):
3193    return {}
3194"#;
3195
3196        // When: extract_routes(source, "main.py")
3197        let routes = extract_routes(source, "main.py");
3198
3199        // Then: path = "/items/{item_id}"
3200        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3201        assert_eq!(routes[0].path, "/items/{item_id}");
3202    }
3203
3204    // FA-RT-04: @router.get with APIRouter prefix
3205    #[test]
3206    fn fa_rt_04_router_get_with_prefix() {
3207        // Given: `router = APIRouter(prefix="/items")` + `@router.get("/{item_id}")`
3208        let source = r#"
3209from fastapi import APIRouter
3210
3211router = APIRouter(prefix="/items")
3212
3213@router.get("/{item_id}")
3214def read_item(item_id: int):
3215    return {}
3216"#;
3217
3218        // When: extract_routes(source, "routes.py")
3219        let routes = extract_routes(source, "routes.py");
3220
3221        // Then: path = "/items/{item_id}"
3222        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3223        assert_eq!(
3224            routes[0].path, "/items/{item_id}",
3225            "expected prefix-resolved path"
3226        );
3227    }
3228
3229    // FA-RT-05: @router.get without prefix
3230    #[test]
3231    fn fa_rt_05_router_get_without_prefix() {
3232        // Given: `router = APIRouter()` + `@router.get("/health")`
3233        let source = r#"
3234from fastapi import APIRouter
3235
3236router = APIRouter()
3237
3238@router.get("/health")
3239def health_check():
3240    return {"status": "ok"}
3241"#;
3242
3243        // When: extract_routes(source, "routes.py")
3244        let routes = extract_routes(source, "routes.py");
3245
3246        // Then: path = "/health"
3247        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3248        assert_eq!(routes[0].path, "/health");
3249    }
3250
3251    // FA-RT-06: non-route decorator ignored
3252    #[test]
3253    fn fa_rt_06_non_route_decorator_ignored() {
3254        // Given: `@pytest.fixture` or `@staticmethod` decorated function
3255        let source = r#"
3256import pytest
3257
3258@pytest.fixture
3259def client():
3260    return None
3261
3262class MyClass:
3263    @staticmethod
3264    def helper():
3265        pass
3266"#;
3267
3268        // When: extract_routes(source, "main.py")
3269        let routes = extract_routes(source, "main.py");
3270
3271        // Then: empty Vec
3272        assert!(
3273            routes.is_empty(),
3274            "expected no routes for non-route decorators, got {:?}",
3275            routes
3276        );
3277    }
3278
3279    // FA-RT-07: dynamic path (non-literal)
3280    #[test]
3281    fn fa_rt_07_dynamic_path_non_literal() {
3282        // Given: `@app.get(some_variable)`
3283        let source = r#"
3284from fastapi import FastAPI
3285app = FastAPI()
3286
3287ROUTE_PATH = "/dynamic"
3288
3289@app.get(ROUTE_PATH)
3290def dynamic_route():
3291    return {}
3292"#;
3293
3294        // When: extract_routes(source, "main.py")
3295        let routes = extract_routes(source, "main.py");
3296
3297        // Then: path = "<dynamic>"
3298        assert_eq!(
3299            routes.len(),
3300            1,
3301            "expected 1 route for dynamic path, got {:?}",
3302            routes
3303        );
3304        assert_eq!(
3305            routes[0].path, "<dynamic>",
3306            "expected <dynamic> for non-literal path argument"
3307        );
3308    }
3309
3310    // FA-RT-08: empty source
3311    #[test]
3312    fn fa_rt_08_empty_source() {
3313        // Given: ""
3314        let source = "";
3315
3316        // When: extract_routes(source, "main.py")
3317        let routes = extract_routes(source, "main.py");
3318
3319        // Then: empty Vec
3320        assert!(routes.is_empty(), "expected empty Vec for empty source");
3321    }
3322
3323    // FA-RT-09: async def handler
3324    #[test]
3325    fn fa_rt_09_async_def_handler() {
3326        // Given: `@app.get("/") async def root(): ...`
3327        let source = r#"
3328from fastapi import FastAPI
3329app = FastAPI()
3330
3331@app.get("/")
3332async def root():
3333    return {"message": "hello"}
3334"#;
3335
3336        // When: extract_routes(source, "main.py")
3337        let routes = extract_routes(source, "main.py");
3338
3339        // Then: handler = "root" (async は無視)
3340        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3341        assert_eq!(
3342            routes[0].handler_name, "root",
3343            "async def should produce handler_name = 'root'"
3344        );
3345    }
3346
3347    // FA-RT-10: multiple decorators on same function
3348    #[test]
3349    fn fa_rt_10_multiple_decorators_on_same_function() {
3350        // Given: `@app.get("/") @require_auth def root(): ...`
3351        let source = r#"
3352from fastapi import FastAPI
3353app = FastAPI()
3354
3355def require_auth(func):
3356    return func
3357
3358@app.get("/")
3359@require_auth
3360def root():
3361    return {}
3362"#;
3363
3364        // When: extract_routes(source, "main.py")
3365        let routes = extract_routes(source, "main.py");
3366
3367        // Then: 1 route (non-route decorators ignored)
3368        assert_eq!(
3369            routes.len(),
3370            1,
3371            "expected exactly 1 route (non-route decorators ignored), got {:?}",
3372            routes
3373        );
3374        assert_eq!(routes[0].http_method, "GET");
3375        assert_eq!(routes[0].path, "/");
3376        assert_eq!(routes[0].handler_name, "root");
3377    }
3378
3379    // FL-RT-01: @bp.route('/verify', methods=['POST'])
3380    #[test]
3381    fn fl_rt_01_blueprint_route_with_post_method() {
3382        // Given: Flask Blueprint with @bp.route('/verify', methods=['POST'])
3383        let source = r#"
3384from flask import Blueprint
3385
3386bp = Blueprint('auth', __name__)
3387
3388@bp.route('/verify', methods=['POST'])
3389def verify_func():
3390    return {}
3391"#;
3392
3393        // When: extract_routes(source, "auth.py")
3394        let routes = extract_routes(source, "auth.py");
3395
3396        // Then: Route{ POST, /verify, verify_func }
3397        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3398        assert_eq!(routes[0].http_method, "POST");
3399        assert_eq!(routes[0].path, "/verify");
3400        assert_eq!(routes[0].handler_name, "verify_func");
3401    }
3402
3403    // FL-RT-02: @bp.route('/health') with no methods → defaults to GET
3404    #[test]
3405    fn fl_rt_02_blueprint_route_no_methods_defaults_to_get() {
3406        // Given: Flask Blueprint with @bp.route('/health') (no methods arg)
3407        let source = r#"
3408from flask import Blueprint
3409
3410bp = Blueprint('health', __name__)
3411
3412@bp.route('/health')
3413def health_func():
3414    return {}
3415"#;
3416
3417        // When: extract_routes(source, "health.py")
3418        let routes = extract_routes(source, "health.py");
3419
3420        // Then: Route{ GET, /health, health_func }
3421        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3422        assert_eq!(routes[0].http_method, "GET");
3423        assert_eq!(routes[0].path, "/health");
3424        assert_eq!(routes[0].handler_name, "health_func");
3425    }
3426
3427    // FL-RT-03: Blueprint with url_prefix + @bp.route → prefix applied
3428    #[test]
3429    fn fl_rt_03_blueprint_url_prefix_applied() {
3430        // Given: Blueprint('auth', __name__, url_prefix='/api/auth') + @bp.route('/verify')
3431        let source = r#"
3432from flask import Blueprint
3433
3434bp = Blueprint('auth', __name__, url_prefix='/api/auth')
3435
3436@bp.route('/verify')
3437def verify_func():
3438    return {}
3439"#;
3440
3441        // When: extract_routes(source, "auth.py")
3442        let routes = extract_routes(source, "auth.py");
3443
3444        // Then: Route{ GET, /api/auth/verify, verify_func }
3445        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3446        assert_eq!(routes[0].http_method, "GET");
3447        assert_eq!(routes[0].path, "/api/auth/verify");
3448        assert_eq!(routes[0].handler_name, "verify_func");
3449    }
3450
3451    // FL-RT-04: @bp.route('/data', methods=['GET', 'POST']) → 2 routes
3452    #[test]
3453    fn fl_rt_04_blueprint_route_multiple_methods() {
3454        // Given: @bp.route('/data', methods=['GET', 'POST'])
3455        let source = r#"
3456from flask import Blueprint
3457
3458bp = Blueprint('data', __name__)
3459
3460@bp.route('/data', methods=['GET', 'POST'])
3461def data_func():
3462    return {}
3463"#;
3464
3465        // When: extract_routes(source, "data.py")
3466        let routes = extract_routes(source, "data.py");
3467
3468        // Then: 2 routes (GET /data and POST /data)
3469        assert_eq!(routes.len(), 2, "expected 2 routes, got {:?}", routes);
3470        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
3471        assert!(methods.contains(&"GET"), "missing GET route");
3472        assert!(methods.contains(&"POST"), "missing POST route");
3473        for r in &routes {
3474            assert_eq!(r.path, "/data");
3475            assert_eq!(r.handler_name, "data_func");
3476        }
3477    }
3478
3479    // FL-RT-05: @app.route('/users/<int:id>') → path = "/users/{id}"
3480    #[test]
3481    fn fl_rt_05_route_with_int_type_param_normalized_to_brace_style() {
3482        // Given: Flask app route with typed parameter `<int:id>`
3483        let source = r#"
3484from flask import Flask
3485
3486app = Flask(__name__)
3487
3488@app.route('/users/<int:id>')
3489def get_user(id):
3490    return {}
3491"#;
3492
3493        // When: extract_routes(source, "main.py")
3494        let routes = extract_routes(source, "main.py");
3495
3496        // Then: path is normalized to "/users/{id}" (braces, type prefix stripped)
3497        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3498        assert_eq!(
3499            routes[0].path, "/users/{id}",
3500            "Flask <int:id> should be normalized to {{id}}"
3501        );
3502    }
3503
3504    // FL-RT-06: @app.route('/files/<path:filepath>') → path = "/files/{filepath}"
3505    #[test]
3506    fn fl_rt_06_route_with_path_type_param_normalized_to_brace_style() {
3507        // Given: Flask app route with path-type parameter `<path:filepath>`
3508        let source = r#"
3509from flask import Flask
3510
3511app = Flask(__name__)
3512
3513@app.route('/files/<path:filepath>')
3514def serve_file(filepath):
3515    return {}
3516"#;
3517
3518        // When: extract_routes(source, "main.py")
3519        let routes = extract_routes(source, "main.py");
3520
3521        // Then: path is normalized to "/files/{filepath}"
3522        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3523        assert_eq!(
3524            routes[0].path, "/files/{filepath}",
3525            "Flask <path:filepath> should be normalized to {{filepath}}"
3526        );
3527    }
3528
3529    // FL-RT-07: @app.route('/api/<anything>') (no type prefix) → path = "/api/{anything}"
3530    #[test]
3531    fn fl_rt_07_route_with_untyped_param_normalized_to_brace_style() {
3532        // Given: Flask app route with untyped parameter `<anything>`
3533        let source = r#"
3534from flask import Flask
3535
3536app = Flask(__name__)
3537
3538@app.route('/api/<anything>')
3539def catch_all(anything):
3540    return {}
3541"#;
3542
3543        // When: extract_routes(source, "main.py")
3544        let routes = extract_routes(source, "main.py");
3545
3546        // Then: path is normalized to "/api/{anything}"
3547        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3548        assert_eq!(
3549            routes[0].path, "/api/{anything}",
3550            "Flask <anything> (untyped) should be normalized to {{anything}}"
3551        );
3552    }
3553}
3554
3555// ---------------------------------------------------------------------------
3556// Django URL conf route extraction
3557// ---------------------------------------------------------------------------
3558
3559const DJANGO_URL_PATTERN_QUERY: &str = include_str!("../queries/django_url_pattern.scm");
3560static DJANGO_URL_PATTERN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
3561
3562static DJANGO_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3563static DJANGO_RE_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
3564
3565const HTTP_METHOD_ANY: &str = "ANY";
3566
3567/// Normalize a Django `path()` URL pattern to Express-style `:param` notation.
3568/// `"users/<int:pk>/"` → `"users/:pk/"`
3569/// `"users/<pk>/"` → `"users/:pk/"`
3570pub fn normalize_django_path(path: &str) -> String {
3571    let re = DJANGO_PATH_RE
3572        .get_or_init(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").expect("invalid regex"));
3573    re.replace_all(path, ":$1").into_owned()
3574}
3575
3576/// Normalize a Django `re_path()` URL pattern.
3577/// Strips leading `^` / trailing `$` anchors and converts `(?P<name>...)` to `:name`.
3578pub fn normalize_re_path(path: &str) -> String {
3579    // Strip leading ^ (only if the very first character is ^)
3580    let s = path.strip_prefix('^').unwrap_or(path);
3581    // Strip trailing $ (only if the very last character is $)
3582    let s = s.strip_suffix('$').unwrap_or(s);
3583    // Replace (?P<name>...) named groups with :name.
3584    // Note: `[^)]*` correctly handles typical Django patterns like `(?P<year>[0-9]{4})`.
3585    // Known limitation: nested parentheses inside a named group (e.g., `(?P<slug>(?:foo|bar))`)
3586    // will not match because `[^)]*` stops at the first `)`. Such patterns are extremely rare
3587    // in Django URL confs and are left as a known constraint.
3588    let re = DJANGO_RE_PATH_RE
3589        .get_or_init(|| regex::Regex::new(r"\(\?P<(\w+)>[^)]*\)").expect("invalid regex"));
3590    re.replace_all(s, ":$1").into_owned()
3591}
3592
3593/// Extract Django URL conf routes from Python source code.
3594pub fn extract_django_routes(source: &str, file_path: &str) -> Vec<Route> {
3595    if source.is_empty() {
3596        return Vec::new();
3597    }
3598
3599    let mut parser = PythonExtractor::parser();
3600    let tree = match parser.parse(source, None) {
3601        Some(t) => t,
3602        None => return Vec::new(),
3603    };
3604    let source_bytes = source.as_bytes();
3605
3606    let query = cached_query(&DJANGO_URL_PATTERN_QUERY_CACHE, DJANGO_URL_PATTERN_QUERY);
3607
3608    let func_idx = query.capture_index_for_name("django.func");
3609    let path_idx = query.capture_index_for_name("django.path");
3610    let handler_idx = query.capture_index_for_name("django.handler");
3611
3612    let mut cursor = QueryCursor::new();
3613    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
3614
3615    let mut routes = Vec::new();
3616    let mut seen = HashSet::new();
3617
3618    while let Some(m) = matches.next() {
3619        let mut func: Option<String> = None;
3620        let mut path_raw: Option<String> = None;
3621        let mut handler: Option<String> = None;
3622
3623        for cap in m.captures {
3624            let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
3625            if func_idx == Some(cap.index) {
3626                func = Some(text);
3627            } else if path_idx == Some(cap.index) {
3628                path_raw = Some(text);
3629            } else if handler_idx == Some(cap.index) {
3630                handler = Some(text);
3631            }
3632        }
3633
3634        let (func, path_raw, handler) = match (func, path_raw, handler) {
3635            (Some(f), Some(p), Some(h)) => (f, p, h),
3636            _ => continue,
3637        };
3638
3639        let raw_path = strip_string_quotes(&path_raw);
3640        let normalized = match func.as_str() {
3641            "re_path" => normalize_re_path(&raw_path),
3642            _ => normalize_django_path(&raw_path),
3643        };
3644
3645        // Deduplicate: same (method, path, handler)
3646        let key = (
3647            HTTP_METHOD_ANY.to_string(),
3648            normalized.clone(),
3649            handler.clone(),
3650        );
3651        if !seen.insert(key) {
3652            continue;
3653        }
3654
3655        routes.push(Route {
3656            http_method: HTTP_METHOD_ANY.to_string(),
3657            path: normalized,
3658            handler_name: handler,
3659            file: file_path.to_string(),
3660        });
3661    }
3662
3663    routes
3664}
3665
3666// ---------------------------------------------------------------------------
3667// Django route extraction tests (DJ-NP-*, DJ-NR-*, DJ-RT-*, DJ-RT-E2E-*)
3668// ---------------------------------------------------------------------------
3669
3670#[cfg(test)]
3671mod django_route_tests {
3672    use super::*;
3673
3674    // -----------------------------------------------------------------------
3675    // Unit: normalize_django_path
3676    // -----------------------------------------------------------------------
3677
3678    // DJ-NP-01: typed parameter
3679    #[test]
3680    fn dj_np_01_typed_parameter() {
3681        // Given: a Django path with a typed parameter "users/<int:pk>/"
3682        // When: normalize_django_path is called
3683        // Then: returns "users/:pk/"
3684        let result = normalize_django_path("users/<int:pk>/");
3685        assert_eq!(result, "users/:pk/");
3686    }
3687
3688    // DJ-NP-02: untyped parameter
3689    #[test]
3690    fn dj_np_02_untyped_parameter() {
3691        // Given: a Django path with an untyped parameter "users/<pk>/"
3692        // When: normalize_django_path is called
3693        // Then: returns "users/:pk/"
3694        let result = normalize_django_path("users/<pk>/");
3695        assert_eq!(result, "users/:pk/");
3696    }
3697
3698    // DJ-NP-03: multiple parameters
3699    #[test]
3700    fn dj_np_03_multiple_parameters() {
3701        // Given: a Django path with multiple parameters
3702        // When: normalize_django_path is called
3703        // Then: returns "posts/:slug/comments/:id/"
3704        let result = normalize_django_path("posts/<slug:slug>/comments/<int:id>/");
3705        assert_eq!(result, "posts/:slug/comments/:id/");
3706    }
3707
3708    // DJ-NP-04: no parameters
3709    #[test]
3710    fn dj_np_04_no_parameters() {
3711        // Given: a Django path with no parameters "users/"
3712        // When: normalize_django_path is called
3713        // Then: returns "users/" unchanged
3714        let result = normalize_django_path("users/");
3715        assert_eq!(result, "users/");
3716    }
3717
3718    // -----------------------------------------------------------------------
3719    // Unit: normalize_re_path
3720    // -----------------------------------------------------------------------
3721
3722    // DJ-NR-01: single named group
3723    #[test]
3724    fn dj_nr_01_single_named_group() {
3725        // Given: a re_path pattern with one named group
3726        // When: normalize_re_path is called
3727        // Then: returns "articles/:year/"
3728        let result = normalize_re_path("^articles/(?P<year>[0-9]{4})/$");
3729        assert_eq!(result, "articles/:year/");
3730    }
3731
3732    // DJ-NR-02: multiple named groups
3733    #[test]
3734    fn dj_nr_02_multiple_named_groups() {
3735        // Given: a re_path pattern with multiple named groups
3736        // When: normalize_re_path is called
3737        // Then: returns ":year/:month/"
3738        let result = normalize_re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$");
3739        assert_eq!(result, ":year/:month/");
3740    }
3741
3742    // DJ-NR-03: no named groups
3743    #[test]
3744    fn dj_nr_03_no_named_groups() {
3745        // Given: a re_path pattern with no named groups
3746        // When: normalize_re_path is called
3747        // Then: anchor stripped → "users/"
3748        let result = normalize_re_path("^users/$");
3749        assert_eq!(result, "users/");
3750    }
3751
3752    // DJ-NR-04: ^ inside character class must not be stripped
3753    #[test]
3754    fn dj_nr_04_character_class_caret_preserved() {
3755        // Given: a re_path pattern with ^ inside a character class [^/]+
3756        // When: normalize_re_path is called
3757        // Then: the ^ inside [] is NOT treated as an anchor: "items/[^/]+/"
3758        let result = normalize_re_path("^items/[^/]+/$");
3759        assert_eq!(result, "items/[^/]+/");
3760    }
3761
3762    // -----------------------------------------------------------------------
3763    // Unit: extract_django_routes
3764    // -----------------------------------------------------------------------
3765
3766    // DJ-RT-01: basic path() with attribute handler (views.user_list)
3767    #[test]
3768    fn dj_rt_01_basic_path_attribute_handler() {
3769        // Given: urlpatterns with path("users/", views.user_list)
3770        let source = r#"
3771from django.urls import path
3772from . import views
3773
3774urlpatterns = [
3775    path("users/", views.user_list),
3776]
3777"#;
3778        // When: extract_django_routes is called
3779        let routes = extract_django_routes(source, "urls.py");
3780
3781        // Then: 1 route, method="ANY", path="users/", handler="user_list"
3782        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3783        assert_eq!(routes[0].http_method, "ANY");
3784        assert_eq!(routes[0].path, "users/");
3785        assert_eq!(routes[0].handler_name, "user_list");
3786    }
3787
3788    // DJ-RT-02: path() with direct import handler
3789    #[test]
3790    fn dj_rt_02_path_direct_import_handler() {
3791        // Given: urlpatterns with path("users/", user_list) — direct function import
3792        let source = r#"
3793from django.urls import path
3794from .views import user_list
3795
3796urlpatterns = [
3797    path("users/", user_list),
3798]
3799"#;
3800        // When: extract_django_routes is called
3801        let routes = extract_django_routes(source, "urls.py");
3802
3803        // Then: 1 route, method="ANY", path="users/", handler="user_list"
3804        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3805        assert_eq!(routes[0].http_method, "ANY");
3806        assert_eq!(routes[0].path, "users/");
3807        assert_eq!(routes[0].handler_name, "user_list");
3808    }
3809
3810    // DJ-RT-03: path() with typed parameter
3811    #[test]
3812    fn dj_rt_03_path_typed_parameter() {
3813        // Given: path("users/<int:pk>/", views.user_detail)
3814        let source = r#"
3815from django.urls import path
3816from . import views
3817
3818urlpatterns = [
3819    path("users/<int:pk>/", views.user_detail),
3820]
3821"#;
3822        // When: extract_django_routes is called
3823        let routes = extract_django_routes(source, "urls.py");
3824
3825        // Then: path = "users/:pk/"
3826        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3827        assert_eq!(routes[0].path, "users/:pk/");
3828    }
3829
3830    // DJ-RT-04: path() with untyped parameter
3831    #[test]
3832    fn dj_rt_04_path_untyped_parameter() {
3833        // Given: path("users/<pk>/", views.user_detail)
3834        let source = r#"
3835from django.urls import path
3836from . import views
3837
3838urlpatterns = [
3839    path("users/<pk>/", views.user_detail),
3840]
3841"#;
3842        // When: extract_django_routes is called
3843        let routes = extract_django_routes(source, "urls.py");
3844
3845        // Then: path = "users/:pk/"
3846        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3847        assert_eq!(routes[0].path, "users/:pk/");
3848    }
3849
3850    // DJ-RT-05: re_path() with named group
3851    #[test]
3852    fn dj_rt_05_re_path_named_group() {
3853        // Given: re_path("^articles/(?P<year>[0-9]{4})/$", views.year_archive)
3854        let source = r#"
3855from django.urls import re_path
3856from . import views
3857
3858urlpatterns = [
3859    re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive),
3860]
3861"#;
3862        // When: extract_django_routes is called
3863        let routes = extract_django_routes(source, "urls.py");
3864
3865        // Then: path = "articles/:year/"
3866        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3867        assert_eq!(routes[0].path, "articles/:year/");
3868    }
3869
3870    // DJ-RT-06: multiple routes — all method "ANY"
3871    #[test]
3872    fn dj_rt_06_multiple_routes() {
3873        // Given: 3 path() entries in urlpatterns
3874        let source = r#"
3875from django.urls import path
3876from . import views
3877
3878urlpatterns = [
3879    path("users/", views.user_list),
3880    path("users/<int:pk>/", views.user_detail),
3881    path("about/", views.about),
3882]
3883"#;
3884        // When: extract_django_routes is called
3885        let routes = extract_django_routes(source, "urls.py");
3886
3887        // Then: 3 routes, all method "ANY"
3888        assert_eq!(routes.len(), 3, "expected 3 routes, got {:?}", routes);
3889        for r in &routes {
3890            assert_eq!(r.http_method, "ANY", "expected method ANY for {:?}", r);
3891        }
3892    }
3893
3894    // DJ-RT-07: path() with name kwarg — name kwarg ignored, handler captured
3895    #[test]
3896    fn dj_rt_07_path_with_name_kwarg() {
3897        // Given: path("login/", views.login_view, name="login")
3898        let source = r#"
3899from django.urls import path
3900from . import views
3901
3902urlpatterns = [
3903    path("login/", views.login_view, name="login"),
3904]
3905"#;
3906        // When: extract_django_routes is called
3907        let routes = extract_django_routes(source, "urls.py");
3908
3909        // Then: 1 route, handler = "login_view" (name kwarg ignored)
3910        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3911        assert_eq!(routes[0].handler_name, "login_view");
3912    }
3913
3914    // DJ-RT-08: empty source
3915    #[test]
3916    fn dj_rt_08_empty_source() {
3917        // Given: ""
3918        // When: extract_django_routes is called
3919        let routes = extract_django_routes("", "urls.py");
3920
3921        // Then: empty Vec
3922        assert!(routes.is_empty(), "expected empty Vec for empty source");
3923    }
3924
3925    // DJ-RT-09: no path/re_path calls
3926    #[test]
3927    fn dj_rt_09_no_path_calls() {
3928        // Given: source with no path() or re_path() calls
3929        let source = r#"
3930from django.db import models
3931
3932class User(models.Model):
3933    name = models.CharField(max_length=100)
3934"#;
3935        // When: extract_django_routes is called
3936        let routes = extract_django_routes(source, "models.py");
3937
3938        // Then: empty Vec
3939        assert!(
3940            routes.is_empty(),
3941            "expected empty Vec for non-URL source, got {:?}",
3942            routes
3943        );
3944    }
3945
3946    // DJ-RT-10: deduplication — same (path, handler) appears twice → 1 route
3947    #[test]
3948    fn dj_rt_10_deduplication() {
3949        // Given: two identical path() entries
3950        let source = r#"
3951from django.urls import path
3952from . import views
3953
3954urlpatterns = [
3955    path("users/", views.user_list),
3956    path("users/", views.user_list),
3957]
3958"#;
3959        // When: extract_django_routes is called
3960        let routes = extract_django_routes(source, "urls.py");
3961
3962        // Then: 1 route (deduplicated)
3963        assert_eq!(
3964            routes.len(),
3965            1,
3966            "expected 1 route after dedup, got {:?}",
3967            routes
3968        );
3969    }
3970
3971    // DJ-RT-11: include() is ignored
3972    #[test]
3973    fn dj_rt_11_include_is_ignored() {
3974        // Given: urlpatterns with include() only
3975        let source = r#"
3976from django.urls import path, include
3977
3978urlpatterns = [
3979    path("api/", include("myapp.urls")),
3980]
3981"#;
3982        // When: extract_django_routes is called
3983        let routes = extract_django_routes(source, "urls.py");
3984
3985        // Then: empty Vec (include() is not a handler)
3986        assert!(
3987            routes.is_empty(),
3988            "expected empty Vec for include()-only urlpatterns, got {:?}",
3989            routes
3990        );
3991    }
3992
3993    // DJ-RT-12: multiple path parameters
3994    #[test]
3995    fn dj_rt_12_multiple_path_parameters() {
3996        // Given: path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail)
3997        let source = r#"
3998from django.urls import path
3999from . import views
4000
4001urlpatterns = [
4002    path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail),
4003]
4004"#;
4005        // When: extract_django_routes is called
4006        let routes = extract_django_routes(source, "urls.py");
4007
4008        // Then: path = "posts/:slug/comments/:id/"
4009        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4010        assert_eq!(routes[0].path, "posts/:slug/comments/:id/");
4011    }
4012
4013    // DJ-RT-13: re_path with multiple named groups
4014    #[test]
4015    fn dj_rt_13_re_path_multiple_named_groups() {
4016        // Given: re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive)
4017        let source = r#"
4018from django.urls import re_path
4019from . import views
4020
4021urlpatterns = [
4022    re_path(r"^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive),
4023]
4024"#;
4025        // When: extract_django_routes is called
4026        let routes = extract_django_routes(source, "urls.py");
4027
4028        // Then: path = ":year/:month/"
4029        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4030        assert_eq!(routes[0].path, ":year/:month/");
4031    }
4032
4033    // -----------------------------------------------------------------------
4034    // Integration: CLI (DJ-RT-E2E-01)
4035    // -----------------------------------------------------------------------
4036
4037    // DJ-RT-E2E-01: observe with Django routes — routes_total = 2
4038    #[test]
4039    fn dj_rt_e2e_01_observe_django_routes_coverage() {
4040        use tempfile::TempDir;
4041
4042        // Given: tempdir with urls.py (2 routes) and test_urls.py
4043        let dir = TempDir::new().unwrap();
4044        let urls_py = dir.path().join("urls.py");
4045        let test_urls_py = dir.path().join("test_urls.py");
4046
4047        std::fs::write(
4048            &urls_py,
4049            r#"from django.urls import path
4050from . import views
4051
4052urlpatterns = [
4053    path("users/", views.user_list),
4054    path("users/<int:pk>/", views.user_detail),
4055]
4056"#,
4057        )
4058        .unwrap();
4059
4060        std::fs::write(
4061            &test_urls_py,
4062            r#"def test_user_list():
4063    pass
4064
4065def test_user_detail():
4066    pass
4067"#,
4068        )
4069        .unwrap();
4070
4071        // When: extract_django_routes from urls.py
4072        let urls_source = std::fs::read_to_string(&urls_py).unwrap();
4073        let urls_path = urls_py.to_string_lossy().into_owned();
4074
4075        let routes = extract_django_routes(&urls_source, &urls_path);
4076
4077        // Then: routes_total = 2
4078        assert_eq!(
4079            routes.len(),
4080            2,
4081            "expected 2 routes extracted from urls.py, got {:?}",
4082            routes
4083        );
4084
4085        // Verify both routes have method "ANY"
4086        for r in &routes {
4087            assert_eq!(r.http_method, "ANY", "expected method ANY, got {:?}", r);
4088        }
4089    }
4090
4091    // -----------------------------------------------------------------------
4092    // PY-IMPORT-04: e2e: `import pkg`, pkg/__init__.py has `from .module import *`,
4093    //               pkg/module.py has Foo -> module.py mapped
4094    // -----------------------------------------------------------------------
4095    #[test]
4096    fn py_import_04_e2e_bare_import_wildcard_barrel_mapped() {
4097        use tempfile::TempDir;
4098
4099        // Given: tempdir with pkg/__init__.py (wildcard re-export) + pkg/module.py
4100        //        and test_foo.py that uses bare `import pkg`
4101        let dir = TempDir::new().unwrap();
4102        let pkg = dir.path().join("pkg");
4103        std::fs::create_dir_all(&pkg).unwrap();
4104
4105        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
4106        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
4107
4108        let tests_dir = dir.path().join("tests");
4109        std::fs::create_dir_all(&tests_dir).unwrap();
4110        let test_content = "import pkg\n\ndef test_foo():\n    assert pkg.Foo()\n";
4111        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4112
4113        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
4114        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4115
4116        let extractor = PythonExtractor::new();
4117        let production_files = vec![module_path.clone()];
4118        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4119            .into_iter()
4120            .collect();
4121
4122        // When: map_test_files_with_imports is called
4123        let result = extractor.map_test_files_with_imports(
4124            &production_files,
4125            &test_sources,
4126            dir.path(),
4127            false,
4128        );
4129
4130        // Then: module.py is matched via bare import + wildcard barrel chain
4131        let mapping = result.iter().find(|m| m.production_file == module_path);
4132        assert!(
4133            mapping.is_some(),
4134            "module.py not mapped; bare import + wildcard barrel should resolve. mappings={:?}",
4135            result
4136        );
4137        let mapping = mapping.unwrap();
4138        assert!(
4139            mapping.test_files.contains(&test_path),
4140            "test_foo.py not in test_files for module.py: {:?}",
4141            mapping.test_files
4142        );
4143    }
4144
4145    // -----------------------------------------------------------------------
4146    // PY-IMPORT-05: e2e: `import pkg`, pkg/__init__.py has `from .module import Foo`
4147    //               (named), pkg/module.py has Foo -> module.py mapped
4148    // -----------------------------------------------------------------------
4149    #[test]
4150    fn py_import_05_e2e_bare_import_named_barrel_mapped() {
4151        use tempfile::TempDir;
4152
4153        // Given: tempdir with pkg/__init__.py (named re-export) + pkg/module.py
4154        //        and test_foo.py that uses bare `import pkg`
4155        let dir = TempDir::new().unwrap();
4156        let pkg = dir.path().join("pkg");
4157        std::fs::create_dir_all(&pkg).unwrap();
4158
4159        std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
4160        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
4161
4162        let tests_dir = dir.path().join("tests");
4163        std::fs::create_dir_all(&tests_dir).unwrap();
4164        let test_content = "import pkg\n\ndef test_foo():\n    assert pkg.Foo()\n";
4165        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4166
4167        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
4168        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4169
4170        let extractor = PythonExtractor::new();
4171        let production_files = vec![module_path.clone()];
4172        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4173            .into_iter()
4174            .collect();
4175
4176        // When: map_test_files_with_imports is called
4177        let result = extractor.map_test_files_with_imports(
4178            &production_files,
4179            &test_sources,
4180            dir.path(),
4181            false,
4182        );
4183
4184        // Then: module.py is matched via bare import + named barrel chain
4185        let mapping = result.iter().find(|m| m.production_file == module_path);
4186        assert!(
4187            mapping.is_some(),
4188            "module.py not mapped; bare import + named barrel should resolve. mappings={:?}",
4189            result
4190        );
4191        let mapping = mapping.unwrap();
4192        assert!(
4193            mapping.test_files.contains(&test_path),
4194            "test_foo.py not in test_files for module.py: {:?}",
4195            mapping.test_files
4196        );
4197    }
4198
4199    // -----------------------------------------------------------------------
4200    // PY-ATTR-01: `import httpx\nhttpx.Client()\n`
4201    //             -> specifier="httpx", symbols=["Client"] (single attribute access)
4202    // -----------------------------------------------------------------------
4203    #[test]
4204    fn py_attr_01_bare_import_single_attribute() {
4205        // Given: source with a bare import and a single attribute access
4206        let source = "import httpx\nhttpx.Client()\n";
4207
4208        // When: extract_all_import_specifiers is called
4209        let extractor = PythonExtractor::new();
4210        let result = extractor.extract_all_import_specifiers(source);
4211
4212        // Then: contains ("httpx", ["Client"]) -- attribute access extracted as symbol
4213        let entry = result.iter().find(|(spec, _)| spec == "httpx");
4214        assert!(entry.is_some(), "httpx not found in {:?}", result);
4215        let (_, symbols) = entry.unwrap();
4216        assert_eq!(
4217            symbols,
4218            &vec!["Client".to_string()],
4219            "expected [\"Client\"] for bare import with attribute access, got {:?}",
4220            symbols
4221        );
4222    }
4223
4224    // -----------------------------------------------------------------------
4225    // PY-ATTR-02: `import httpx\nhttpx.Client()\nhttpx.get()\n`
4226    //             -> specifier="httpx", symbols contains "Client" and "get" (multiple attributes)
4227    // -----------------------------------------------------------------------
4228    #[test]
4229    fn py_attr_02_bare_import_multiple_attributes() {
4230        // Given: source with a bare import and multiple attribute accesses
4231        let source = "import httpx\nhttpx.Client()\nhttpx.get()\n";
4232
4233        // When: extract_all_import_specifiers is called
4234        let extractor = PythonExtractor::new();
4235        let result = extractor.extract_all_import_specifiers(source);
4236
4237        // Then: contains ("httpx", [...]) with both "Client" and "get"
4238        let entry = result.iter().find(|(spec, _)| spec == "httpx");
4239        assert!(entry.is_some(), "httpx not found in {:?}", result);
4240        let (_, symbols) = entry.unwrap();
4241        assert!(
4242            symbols.contains(&"Client".to_string()),
4243            "Client not in symbols: {:?}",
4244            symbols
4245        );
4246        assert!(
4247            symbols.contains(&"get".to_string()),
4248            "get not in symbols: {:?}",
4249            symbols
4250        );
4251    }
4252
4253    // -----------------------------------------------------------------------
4254    // PY-ATTR-03: `import httpx\nhttpx.Client()\nhttpx.Client()\n`
4255    //             -> specifier="httpx", symbols=["Client"] (deduplication)
4256    // -----------------------------------------------------------------------
4257    #[test]
4258    fn py_attr_03_bare_import_deduplicated_attributes() {
4259        // Given: source with a bare import and duplicate attribute accesses
4260        let source = "import httpx\nhttpx.Client()\nhttpx.Client()\n";
4261
4262        // When: extract_all_import_specifiers is called
4263        let extractor = PythonExtractor::new();
4264        let result = extractor.extract_all_import_specifiers(source);
4265
4266        // Then: contains ("httpx", ["Client"]) -- duplicates removed
4267        let entry = result.iter().find(|(spec, _)| spec == "httpx");
4268        assert!(entry.is_some(), "httpx not found in {:?}", result);
4269        let (_, symbols) = entry.unwrap();
4270        assert_eq!(
4271            symbols,
4272            &vec!["Client".to_string()],
4273            "expected [\"Client\"] with deduplication, got {:?}",
4274            symbols
4275        );
4276    }
4277
4278    // -----------------------------------------------------------------------
4279    // PY-ATTR-04: `import httpx\n` (no attribute access)
4280    //             -> specifier="httpx", symbols=[] (fallback: match all)
4281    //
4282    // NOTE: This test covers the same input as PY-IMPORT-01 but explicitly
4283    //       verifies the "no attribute access → symbols=[] fallback" contract
4284    //       introduced in Phase 16. PY-IMPORT-01 verifies the pre-Phase 16
4285    //       baseline; this test documents the Phase 16 intentional behaviour.
4286    // -----------------------------------------------------------------------
4287    #[test]
4288    fn py_attr_04_bare_import_no_attribute_fallback() {
4289        // Given: source with a bare import but no attribute access
4290        let source = "import httpx\n";
4291
4292        // When: extract_all_import_specifiers is called
4293        let extractor = PythonExtractor::new();
4294        let result = extractor.extract_all_import_specifiers(source);
4295
4296        // Then: contains ("httpx", []) -- no attribute access means match-all fallback
4297        let entry = result.iter().find(|(spec, _)| spec == "httpx");
4298        assert!(
4299            entry.is_some(),
4300            "httpx not found in {:?}; bare import without attribute access should be included",
4301            result
4302        );
4303        let (_, symbols) = entry.unwrap();
4304        assert!(
4305            symbols.is_empty(),
4306            "expected empty symbols (fallback) for bare import with no attribute access, got {:?}",
4307            symbols
4308        );
4309    }
4310
4311    // -----------------------------------------------------------------------
4312    // PY-ATTR-05: `from httpx import Client\n`
4313    //             -> specifier="httpx", symbols=["Client"]
4314    //             (regression: Phase 16 changes must not affect from-import)
4315    //
4316    // NOTE: This is a regression test verifying that Phase 16 attribute-access
4317    //       filtering does not change the behaviour of `from X import Y` paths.
4318    //       PY-IMPORT-03 tests the same input as a baseline; this test
4319    //       explicitly documents the Phase 16 non-regression requirement.
4320    // -----------------------------------------------------------------------
4321    #[test]
4322    fn py_attr_05_from_import_regression() {
4323        // Given: source with a from-import (must not be affected by Phase 16 changes)
4324        let source = "from httpx import Client\n";
4325
4326        // When: extract_all_import_specifiers is called
4327        let extractor = PythonExtractor::new();
4328        let result = extractor.extract_all_import_specifiers(source);
4329
4330        // Then: contains ("httpx", ["Client"]) -- from-import path unchanged
4331        let entry = result.iter().find(|(spec, _)| spec == "httpx");
4332        assert!(entry.is_some(), "httpx not found in {:?}", result);
4333        let (_, symbols) = entry.unwrap();
4334        assert!(
4335            symbols.contains(&"Client".to_string()),
4336            "Client not in symbols: {:?}",
4337            symbols
4338        );
4339    }
4340
4341    // -----------------------------------------------------------------------
4342    // PY-ATTR-06: e2e: `import pkg\npkg.Foo()\n`, barrel `from .mod import Foo`
4343    //             and `from .bar import Bar` -> mod.py mapped, bar.py NOT mapped
4344    //             (attribute-access filtering narrows barrel resolution)
4345    // -----------------------------------------------------------------------
4346    #[test]
4347    fn py_attr_06_e2e_attribute_access_narrows_barrel_mapping() {
4348        use tempfile::TempDir;
4349
4350        // Given: tempdir with:
4351        //   pkg/__init__.py: re-exports Foo from .mod and Bar from .bar
4352        //   pkg/mod.py: defines Foo
4353        //   pkg/bar.py: defines Bar
4354        //   tests/test_foo.py: uses bare `import pkg` and accesses only `pkg.Foo()`
4355        let dir = TempDir::new().unwrap();
4356        let pkg = dir.path().join("pkg");
4357        std::fs::create_dir_all(&pkg).unwrap();
4358
4359        std::fs::write(
4360            pkg.join("__init__.py"),
4361            "from .mod import Foo\nfrom .bar import Bar\n",
4362        )
4363        .unwrap();
4364        std::fs::write(pkg.join("mod.py"), "def Foo(): pass\n").unwrap();
4365        std::fs::write(pkg.join("bar.py"), "def Bar(): pass\n").unwrap();
4366
4367        let tests_dir = dir.path().join("tests");
4368        std::fs::create_dir_all(&tests_dir).unwrap();
4369        // Test file only accesses pkg.Foo, not pkg.Bar
4370        let test_content = "import pkg\npkg.Foo()\n";
4371        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
4372
4373        let mod_path = pkg.join("mod.py").to_string_lossy().into_owned();
4374        let bar_path = pkg.join("bar.py").to_string_lossy().into_owned();
4375        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
4376
4377        let extractor = PythonExtractor::new();
4378        let production_files = vec![mod_path.clone(), bar_path.clone()];
4379        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4380            .into_iter()
4381            .collect();
4382
4383        // When: map_test_files_with_imports is called
4384        let result = extractor.map_test_files_with_imports(
4385            &production_files,
4386            &test_sources,
4387            dir.path(),
4388            false,
4389        );
4390
4391        // Then: mod.py is mapped (Foo is accessed via pkg.Foo())
4392        let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
4393        assert!(
4394            mod_mapping.is_some(),
4395            "mod.py not mapped; pkg.Foo() should resolve to mod.py via barrel. mappings={:?}",
4396            result
4397        );
4398        assert!(
4399            mod_mapping.unwrap().test_files.contains(&test_path),
4400            "test_foo.py not in test_files for mod.py: {:?}",
4401            mod_mapping.unwrap().test_files
4402        );
4403
4404        // Then: bar.py is NOT mapped (Bar is not accessed -- pkg.Bar() is absent)
4405        let bar_mapping = result.iter().find(|m| m.production_file == bar_path);
4406        let bar_not_mapped = bar_mapping
4407            .map(|m| !m.test_files.contains(&test_path))
4408            .unwrap_or(true);
4409        assert!(
4410            bar_not_mapped,
4411            "bar.py should NOT be mapped for test_foo.py (pkg.Bar() is not accessed), but got: {:?}",
4412            bar_mapping
4413        );
4414    }
4415
4416    // -----------------------------------------------------------------------
4417    // PY-L1X-01: stem-only fallback: tests/test_client.py -> pkg/_client.py (cross-directory)
4418    //
4419    // The key scenario: test file is in tests/ but prod is in pkg/.
4420    // L1 core uses (dir, stem) pair, so tests/test_client.py (dir=tests/) does NOT
4421    // match pkg/_client.py (dir=pkg/) via L1 core.
4422    // stem-only fallback should match them via stem "client" regardless of directory.
4423    // The test file has NO import statements to avoid L2 from resolving the mapping.
4424    // -----------------------------------------------------------------------
4425    #[test]
4426    fn py_l1x_01_stem_only_fallback_cross_directory() {
4427        use tempfile::TempDir;
4428
4429        // Given: pkg/_client.py (prod) and tests/test_client.py (test, NO imports)
4430        //        L1 core cannot match (different dirs: pkg/ vs tests/)
4431        //        L2 cannot match (no import statements)
4432        //        stem-only fallback should match via stem "client"
4433        let dir = TempDir::new().unwrap();
4434        let pkg = dir.path().join("pkg");
4435        std::fs::create_dir_all(&pkg).unwrap();
4436        let tests_dir = dir.path().join("tests");
4437        std::fs::create_dir_all(&tests_dir).unwrap();
4438
4439        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4440
4441        // No imports -- forces reliance on stem-only fallback (not L2)
4442        let test_content = "def test_client():\n    pass\n";
4443        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4444
4445        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4446        let test_path = tests_dir
4447            .join("test_client.py")
4448            .to_string_lossy()
4449            .into_owned();
4450
4451        let extractor = PythonExtractor::new();
4452        let production_files = vec![client_path.clone()];
4453        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4454            .into_iter()
4455            .collect();
4456
4457        // When: map_test_files_with_imports is called
4458        let result = extractor.map_test_files_with_imports(
4459            &production_files,
4460            &test_sources,
4461            dir.path(),
4462            false,
4463        );
4464
4465        // Then: test_client.py is mapped to pkg/_client.py via stem-only fallback
4466        let mapping = result.iter().find(|m| m.production_file == client_path);
4467        assert!(
4468            mapping.is_some(),
4469            "pkg/_client.py not mapped; stem-only fallback should match across directories. mappings={:?}",
4470            result
4471        );
4472        let mapping = mapping.unwrap();
4473        assert!(
4474            mapping.test_files.contains(&test_path),
4475            "test_client.py not in test_files for pkg/_client.py: {:?}",
4476            mapping.test_files
4477        );
4478    }
4479
4480    // -----------------------------------------------------------------------
4481    // PY-L1X-02: stem-only: tests/test_decoders.py -> pkg/_decoders.py (_ prefix prod)
4482    //
4483    // production_stem strips leading _ so "_decoders" -> "decoders".
4484    // test_stem strips "test_" prefix so "test_decoders" -> "decoders".
4485    // stem-only fallback should match them even though dirs differ.
4486    // -----------------------------------------------------------------------
4487    #[test]
4488    fn py_l1x_02_stem_only_underscore_prefix_prod() {
4489        use tempfile::TempDir;
4490
4491        // Given: pkg/_decoders.py and tests/test_decoders.py (no imports)
4492        let dir = TempDir::new().unwrap();
4493        let pkg = dir.path().join("pkg");
4494        std::fs::create_dir_all(&pkg).unwrap();
4495        let tests_dir = dir.path().join("tests");
4496        std::fs::create_dir_all(&tests_dir).unwrap();
4497
4498        std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4499
4500        // No imports -- forces reliance on stem-only fallback
4501        let test_content = "def test_decode():\n    pass\n";
4502        std::fs::write(tests_dir.join("test_decoders.py"), test_content).unwrap();
4503
4504        let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4505        let test_path = tests_dir
4506            .join("test_decoders.py")
4507            .to_string_lossy()
4508            .into_owned();
4509
4510        let extractor = PythonExtractor::new();
4511        let production_files = vec![decoders_path.clone()];
4512        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4513            .into_iter()
4514            .collect();
4515
4516        // When: map_test_files_with_imports is called
4517        let result = extractor.map_test_files_with_imports(
4518            &production_files,
4519            &test_sources,
4520            dir.path(),
4521            false,
4522        );
4523
4524        // Then: test_decoders.py is mapped to pkg/_decoders.py via stem-only fallback
4525        //       (production_stem strips '_' prefix: "_decoders" -> "decoders")
4526        let mapping = result.iter().find(|m| m.production_file == decoders_path);
4527        assert!(
4528            mapping.is_some(),
4529            "pkg/_decoders.py not mapped; stem-only fallback should strip _ prefix and match. mappings={:?}",
4530            result
4531        );
4532        let mapping = mapping.unwrap();
4533        assert!(
4534            mapping.test_files.contains(&test_path),
4535            "test_decoders.py not in test_files for pkg/_decoders.py: {:?}",
4536            mapping.test_files
4537        );
4538    }
4539
4540    // -----------------------------------------------------------------------
4541    // PY-L1X-03: stem-only: tests/test_asgi.py -> pkg/transports/asgi.py (subdirectory)
4542    //
4543    // Prod is in a subdirectory (pkg/transports/), test is in tests/.
4544    // stem "asgi" should match across any directory depth.
4545    // -----------------------------------------------------------------------
4546    #[test]
4547    fn py_l1x_03_stem_only_subdirectory_prod() {
4548        use tempfile::TempDir;
4549
4550        // Given: pkg/transports/asgi.py and tests/test_asgi.py (no imports)
4551        let dir = TempDir::new().unwrap();
4552        let transports = dir.path().join("pkg").join("transports");
4553        std::fs::create_dir_all(&transports).unwrap();
4554        let tests_dir = dir.path().join("tests");
4555        std::fs::create_dir_all(&tests_dir).unwrap();
4556
4557        std::fs::write(
4558            transports.join("asgi.py"),
4559            "class ASGITransport:\n    pass\n",
4560        )
4561        .unwrap();
4562
4563        // No imports -- forces reliance on stem-only fallback
4564        let test_content = "def test_asgi_transport():\n    pass\n";
4565        std::fs::write(tests_dir.join("test_asgi.py"), test_content).unwrap();
4566
4567        let asgi_path = transports.join("asgi.py").to_string_lossy().into_owned();
4568        let test_path = tests_dir
4569            .join("test_asgi.py")
4570            .to_string_lossy()
4571            .into_owned();
4572
4573        let extractor = PythonExtractor::new();
4574        let production_files = vec![asgi_path.clone()];
4575        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4576            .into_iter()
4577            .collect();
4578
4579        // When: map_test_files_with_imports is called
4580        let result = extractor.map_test_files_with_imports(
4581            &production_files,
4582            &test_sources,
4583            dir.path(),
4584            false,
4585        );
4586
4587        // Then: test_asgi.py is mapped to pkg/transports/asgi.py
4588        //       (stem "asgi" matches across directory depth)
4589        let mapping = result.iter().find(|m| m.production_file == asgi_path);
4590        assert!(
4591            mapping.is_some(),
4592            "pkg/transports/asgi.py not mapped; stem 'asgi' should match across directory depth. mappings={:?}",
4593            result
4594        );
4595        let mapping = mapping.unwrap();
4596        assert!(
4597            mapping.test_files.contains(&test_path),
4598            "test_asgi.py not in test_files for pkg/transports/asgi.py: {:?}",
4599            mapping.test_files
4600        );
4601    }
4602
4603    // -----------------------------------------------------------------------
4604    // PY-L1X-04: stem collision -- no imports -> L1 stem-only fallback defers to L2
4605    //
4606    // When multiple prod files share the same stem and the test has no imports,
4607    // the collision guard prevents L1 stem-only from mapping to any of them.
4608    // Precision takes priority over recall in this case.
4609    // -----------------------------------------------------------------------
4610    #[test]
4611    fn py_l1x_04_stem_collision_defers_to_l2() {
4612        use tempfile::TempDir;
4613
4614        // Given: pkg/client.py, pkg/aio/client.py, and tests/test_client.py (no imports)
4615        //        Both have stem "client"; test has stem "client" but no import -> collision guard fires
4616        let dir = TempDir::new().unwrap();
4617        let pkg = dir.path().join("pkg");
4618        let pkg_aio = pkg.join("aio");
4619        std::fs::create_dir_all(&pkg).unwrap();
4620        std::fs::create_dir_all(&pkg_aio).unwrap();
4621        let tests_dir = dir.path().join("tests");
4622        std::fs::create_dir_all(&tests_dir).unwrap();
4623
4624        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
4625        std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n    pass\n").unwrap();
4626
4627        // No imports -- collision guard should prevent stem-only fallback from mapping
4628        let test_content = "def test_client():\n    pass\n";
4629        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4630
4631        let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4632        let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4633        let test_path = tests_dir
4634            .join("test_client.py")
4635            .to_string_lossy()
4636            .into_owned();
4637
4638        let extractor = PythonExtractor::new();
4639        let production_files = vec![client_path.clone(), aio_client_path.clone()];
4640        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4641            .into_iter()
4642            .collect();
4643
4644        // When: map_test_files_with_imports is called
4645        let result = extractor.map_test_files_with_imports(
4646            &production_files,
4647            &test_sources,
4648            dir.path(),
4649            false,
4650        );
4651
4652        // Then: test_client.py is NOT mapped to pkg/client.py (collision guard defers to L2)
4653        let client_mapped = result
4654            .iter()
4655            .find(|m| m.production_file == client_path)
4656            .map(|m| m.test_files.contains(&test_path))
4657            .unwrap_or(false);
4658        assert!(
4659            !client_mapped,
4660            "test_client.py should NOT be mapped to pkg/client.py (stem collision -> defer to L2). mappings={:?}",
4661            result
4662        );
4663
4664        let aio_mapped = result
4665            .iter()
4666            .find(|m| m.production_file == aio_client_path)
4667            .map(|m| m.test_files.contains(&test_path))
4668            .unwrap_or(false);
4669        assert!(
4670            !aio_mapped,
4671            "test_client.py should NOT be mapped to pkg/aio/client.py (stem collision -> defer to L2). mappings={:?}",
4672            result
4673        );
4674    }
4675
4676    // -----------------------------------------------------------------------
4677    // PY-L1X-05: L1 core match already found -> stem-only fallback does NOT fire
4678    //
4679    // When L1 core (dir, stem) already matches, stem-only fallback should be
4680    // suppressed for that test to avoid adding cross-directory duplicates.
4681    // Note: production file uses svc/ (not tests/) since Phase 20 excludes tests/ files.
4682    // -----------------------------------------------------------------------
4683    #[test]
4684    fn py_l1x_05_l1_core_match_suppresses_fallback() {
4685        use tempfile::TempDir;
4686
4687        // Given: svc/client.py (L1 core match: dir=svc/, stem=client)
4688        //        pkg/client.py (would match via stem-only fallback if L1 core is absent)
4689        //        svc/test_client.py (no imports)
4690        let dir = TempDir::new().unwrap();
4691        let pkg = dir.path().join("pkg");
4692        let svc = dir.path().join("svc");
4693        std::fs::create_dir_all(&pkg).unwrap();
4694        std::fs::create_dir_all(&svc).unwrap();
4695
4696        std::fs::write(svc.join("client.py"), "class Client:\n    pass\n").unwrap();
4697        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
4698
4699        // No imports -- avoids L2 influence; only L1 core and stem-only fallback apply
4700        let test_content = "def test_client():\n    pass\n";
4701        std::fs::write(svc.join("test_client.py"), test_content).unwrap();
4702
4703        let svc_client_path = svc.join("client.py").to_string_lossy().into_owned();
4704        let pkg_client_path = pkg.join("client.py").to_string_lossy().into_owned();
4705        let test_path = svc.join("test_client.py").to_string_lossy().into_owned();
4706
4707        let extractor = PythonExtractor::new();
4708        let production_files = vec![svc_client_path.clone(), pkg_client_path.clone()];
4709        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4710            .into_iter()
4711            .collect();
4712
4713        // When: map_test_files_with_imports is called
4714        let result = extractor.map_test_files_with_imports(
4715            &production_files,
4716            &test_sources,
4717            dir.path(),
4718            false,
4719        );
4720
4721        // Then: test_client.py is mapped to svc/client.py only (L1 core match)
4722        let svc_client_mapped = result
4723            .iter()
4724            .find(|m| m.production_file == svc_client_path)
4725            .map(|m| m.test_files.contains(&test_path))
4726            .unwrap_or(false);
4727        assert!(
4728            svc_client_mapped,
4729            "test_client.py should be mapped to svc/client.py via L1 core. mappings={:?}",
4730            result
4731        );
4732
4733        // Then: fallback does NOT add pkg/client.py (L1 core match suppresses fallback)
4734        let pkg_not_mapped = result
4735            .iter()
4736            .find(|m| m.production_file == pkg_client_path)
4737            .map(|m| !m.test_files.contains(&test_path))
4738            .unwrap_or(true);
4739        assert!(
4740            pkg_not_mapped,
4741            "pkg/client.py should NOT be mapped (L1 core match suppresses stem-only fallback). mappings={:?}",
4742            result
4743        );
4744    }
4745
4746    // -----------------------------------------------------------------------
4747    // PY-L1X-06: stem collision + L2 import -> maps to the correct file via ImportTracing
4748    //
4749    // When multiple prod files share the same stem, stem-only fallback defers to L2.
4750    // If the test has a direct import, L2 resolves it to the correct file.
4751    // -----------------------------------------------------------------------
4752    #[test]
4753    fn py_l1x_06_stem_collision_with_l2_import_resolves_correctly() {
4754        use std::collections::HashMap;
4755        use tempfile::TempDir;
4756
4757        // Given: pkg/client.py, pkg/aio/client.py (same stem "client")
4758        //        tests/test_client.py has "from pkg.client import Client" (direct import)
4759        let dir = TempDir::new().unwrap();
4760        let pkg = dir.path().join("pkg");
4761        let pkg_aio = pkg.join("aio");
4762        std::fs::create_dir_all(&pkg).unwrap();
4763        std::fs::create_dir_all(&pkg_aio).unwrap();
4764        let tests_dir = dir.path().join("tests");
4765        std::fs::create_dir_all(&tests_dir).unwrap();
4766
4767        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
4768        std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n    pass\n").unwrap();
4769
4770        // Direct import to pkg.client -> L2 resolves to pkg/client.py
4771        let test_content =
4772            "from pkg.client import Client\n\ndef test_client():\n    assert Client()\n";
4773        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4774
4775        let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4776        let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4777        let test_path = tests_dir
4778            .join("test_client.py")
4779            .to_string_lossy()
4780            .into_owned();
4781
4782        let extractor = PythonExtractor::new();
4783        let production_files = vec![client_path.clone(), aio_client_path.clone()];
4784        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4785            .into_iter()
4786            .collect();
4787
4788        // When: map_test_files_with_imports is called
4789        let result = extractor.map_test_files_with_imports(
4790            &production_files,
4791            &test_sources,
4792            dir.path(),
4793            false,
4794        );
4795
4796        // Then: test_client.py is mapped to pkg/client.py only (L2 ImportTracing)
4797        let client_mapping = result.iter().find(|m| m.production_file == client_path);
4798        assert!(
4799            client_mapping.is_some(),
4800            "pkg/client.py not found in mappings: {:?}",
4801            result
4802        );
4803        let client_mapping = client_mapping.unwrap();
4804        assert!(
4805            client_mapping.test_files.contains(&test_path),
4806            "test_client.py should be mapped to pkg/client.py via L2. mappings={:?}",
4807            result
4808        );
4809        assert_eq!(
4810            client_mapping.strategy,
4811            MappingStrategy::ImportTracing,
4812            "strategy should be ImportTracing (L2), got {:?}",
4813            client_mapping.strategy
4814        );
4815
4816        // Then: pkg/aio/client.py is NOT mapped (collision guard + L2 resolves to pkg/client.py)
4817        let aio_mapped = result
4818            .iter()
4819            .find(|m| m.production_file == aio_client_path)
4820            .map(|m| m.test_files.contains(&test_path))
4821            .unwrap_or(false);
4822        assert!(
4823            !aio_mapped,
4824            "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4825            result
4826        );
4827    }
4828
4829    // -----------------------------------------------------------------------
4830    // PY-L1X-07: stem collision + barrel import -> L2 barrel resolves to correct file
4831    //
4832    // When multiple prod files share the same stem, and the test imports via barrel,
4833    // L2 barrel tracing resolves to the correct file (not all files with that stem).
4834    // -----------------------------------------------------------------------
4835    #[test]
4836    fn py_l1x_07_stem_collision_with_barrel_import_resolves_correctly() {
4837        use std::collections::HashMap;
4838        use tempfile::TempDir;
4839
4840        // Given: pkg/__init__.py (barrel: "from .client import Client")
4841        //        pkg/client.py, pkg/aio/client.py (same stem "client")
4842        //        tests/test_client.py has "from pkg import Client" (barrel import)
4843        let dir = TempDir::new().unwrap();
4844        let pkg = dir.path().join("pkg");
4845        let pkg_aio = pkg.join("aio");
4846        std::fs::create_dir_all(&pkg).unwrap();
4847        std::fs::create_dir_all(&pkg_aio).unwrap();
4848        let tests_dir = dir.path().join("tests");
4849        std::fs::create_dir_all(&tests_dir).unwrap();
4850
4851        // Barrel re-exports Client from pkg/client.py (not pkg/aio/client.py)
4852        std::fs::write(pkg.join("__init__.py"), "from .client import Client\n").unwrap();
4853        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
4854        std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n    pass\n").unwrap();
4855
4856        // Import via barrel -> L2 barrel tracing should resolve to pkg/client.py
4857        let test_content = "from pkg import Client\n\ndef test_client():\n    assert Client()\n";
4858        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4859
4860        let client_path = pkg.join("client.py").to_string_lossy().into_owned();
4861        let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
4862        let test_path = tests_dir
4863            .join("test_client.py")
4864            .to_string_lossy()
4865            .into_owned();
4866
4867        let extractor = PythonExtractor::new();
4868        let production_files = vec![client_path.clone(), aio_client_path.clone()];
4869        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4870            .into_iter()
4871            .collect();
4872
4873        // When: map_test_files_with_imports is called
4874        let result = extractor.map_test_files_with_imports(
4875            &production_files,
4876            &test_sources,
4877            dir.path(),
4878            false,
4879        );
4880
4881        // Then: collision guard prevents L1 stem-only from mapping to both files
4882        //       L2 barrel import resolves to pkg/client.py (via __init__.py re-export)
4883        let client_mapped = result
4884            .iter()
4885            .find(|m| m.production_file == client_path)
4886            .map(|m| m.test_files.contains(&test_path))
4887            .unwrap_or(false);
4888        assert!(
4889            client_mapped,
4890            "test_client.py should be mapped to pkg/client.py via barrel L2. mappings={:?}",
4891            result
4892        );
4893
4894        // Then: pkg/aio/client.py is NOT mapped (barrel only re-exports pkg.client)
4895        let aio_mapped = result
4896            .iter()
4897            .find(|m| m.production_file == aio_client_path)
4898            .map(|m| m.test_files.contains(&test_path))
4899            .unwrap_or(false);
4900        assert!(
4901            !aio_mapped,
4902            "test_client.py should NOT be mapped to pkg/aio/client.py. mappings={:?}",
4903            result
4904        );
4905    }
4906
4907    // -----------------------------------------------------------------------
4908    // PY-SUP-01: barrel suppression: L1 stem-only matched test does not get barrel fan-out
4909    //
4910    // The httpx FP scenario:
4911    // - tests/test_client.py has NO specific imports (bare `import pkg` + no attribute access)
4912    // - Without barrel suppression: `import pkg` -> barrel -> _client.py + _utils.py (FP!)
4913    // - With stem-only L1 match + barrel suppression:
4914    //   test_client.py -> L1 stem-only -> _client.py only (barrel _utils.py suppressed)
4915    // -----------------------------------------------------------------------
4916    #[test]
4917    fn py_sup_01_barrel_suppression_l1_matched_no_barrel_fan_out() {
4918        use tempfile::TempDir;
4919
4920        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
4921        //        tests/test_client.py: `import pkg` (bare import, NO attribute access)
4922        //        L1 stem-only fallback: test_client.py -> pkg/_client.py (stem "client")
4923        let dir = TempDir::new().unwrap();
4924        let pkg = dir.path().join("pkg");
4925        std::fs::create_dir_all(&pkg).unwrap();
4926        let tests_dir = dir.path().join("tests");
4927        std::fs::create_dir_all(&tests_dir).unwrap();
4928
4929        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4930        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4931        std::fs::write(
4932            pkg.join("__init__.py"),
4933            "from ._client import Client\nfrom ._utils import format_url\n",
4934        )
4935        .unwrap();
4936
4937        // bare `import pkg` with NO attribute access -> symbols=[] -> barrel fan-out to all
4938        // Without barrel suppression: _client.py AND _utils.py both mapped (FP for _utils)
4939        // With barrel suppression (L1 matched): only _client.py mapped
4940        let test_content = "import pkg\n\ndef test_client():\n    pass\n";
4941        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4942
4943        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4944        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4945        let test_path = tests_dir
4946            .join("test_client.py")
4947            .to_string_lossy()
4948            .into_owned();
4949
4950        let extractor = PythonExtractor::new();
4951        let production_files = vec![client_path.clone(), utils_path.clone()];
4952        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4953            .into_iter()
4954            .collect();
4955
4956        // When: map_test_files_with_imports is called
4957        let result = extractor.map_test_files_with_imports(
4958            &production_files,
4959            &test_sources,
4960            dir.path(),
4961            false,
4962        );
4963
4964        // Then: _client.py IS mapped (L1 stem-only match)
4965        let client_mapped = result
4966            .iter()
4967            .find(|m| m.production_file == client_path)
4968            .map(|m| m.test_files.contains(&test_path))
4969            .unwrap_or(false);
4970        assert!(
4971            client_mapped,
4972            "pkg/_client.py should be mapped via L1 stem-only. mappings={:?}",
4973            result
4974        );
4975
4976        // Then: _utils.py is NOT mapped (barrel fan-out suppressed because L1 stem-only matched)
4977        let utils_not_mapped = result
4978            .iter()
4979            .find(|m| m.production_file == utils_path)
4980            .map(|m| !m.test_files.contains(&test_path))
4981            .unwrap_or(true);
4982        assert!(
4983            utils_not_mapped,
4984            "pkg/_utils.py should NOT be mapped (barrel suppression for L1-matched test_client.py). mappings={:?}",
4985            result
4986        );
4987    }
4988
4989    // -----------------------------------------------------------------------
4990    // PY-SUP-02: barrel suppression: L1 stem-only matched test still gets direct imports
4991    //
4992    // Direct imports (from pkg._utils import format_url) bypass barrel resolution.
4993    // Even if L1 stem-only matches _client.py, direct imports to _utils.py are added.
4994    // -----------------------------------------------------------------------
4995    #[test]
4996    fn py_sup_02_barrel_suppression_direct_import_still_added() {
4997        use tempfile::TempDir;
4998
4999        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
5000        //        tests/test_client.py:
5001        //          - `import pkg` (bare import, no attribute access -> would fan-out to barrel)
5002        //          - `from pkg._utils import format_url` (direct import)
5003        //        L1 stem-only: test_client.py -> _client.py
5004        let dir = TempDir::new().unwrap();
5005        let pkg = dir.path().join("pkg");
5006        std::fs::create_dir_all(&pkg).unwrap();
5007        let tests_dir = dir.path().join("tests");
5008        std::fs::create_dir_all(&tests_dir).unwrap();
5009
5010        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5011        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5012        std::fs::write(
5013            pkg.join("__init__.py"),
5014            "from ._client import Client\nfrom ._utils import format_url\n",
5015        )
5016        .unwrap();
5017
5018        // Direct import to _utils -- this is NOT via barrel, so suppression does not apply
5019        let test_content =
5020            "import pkg\nfrom pkg._utils import format_url\n\ndef test_client():\n    assert format_url('http://x')\n";
5021        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5022
5023        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5024        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5025        let test_path = tests_dir
5026            .join("test_client.py")
5027            .to_string_lossy()
5028            .into_owned();
5029
5030        let extractor = PythonExtractor::new();
5031        let production_files = vec![client_path.clone(), utils_path.clone()];
5032        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5033            .into_iter()
5034            .collect();
5035
5036        // When: map_test_files_with_imports is called
5037        let result = extractor.map_test_files_with_imports(
5038            &production_files,
5039            &test_sources,
5040            dir.path(),
5041            false,
5042        );
5043
5044        // Then: _utils.py IS mapped (direct import bypasses barrel suppression)
5045        let utils_mapped = result
5046            .iter()
5047            .find(|m| m.production_file == utils_path)
5048            .map(|m| m.test_files.contains(&test_path))
5049            .unwrap_or(false);
5050        assert!(
5051            utils_mapped,
5052            "pkg/_utils.py should be mapped via direct import (not barrel). mappings={:?}",
5053            result
5054        );
5055    }
5056
5057    // -----------------------------------------------------------------------
5058    // PY-SUP-03: barrel suppression: L1-unmatched test gets barrel fan-out as usual
5059    //
5060    // A test with NO matching stem in prod files should still get barrel fan-out.
5061    // Barrel suppression only applies to L1 stem-only matched tests.
5062    // -----------------------------------------------------------------------
5063    #[test]
5064    fn py_sup_03_barrel_suppression_l1_unmatched_gets_barrel() {
5065        use tempfile::TempDir;
5066
5067        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
5068        //        tests/test_exported_members.py: `import pkg` (bare import, no attr access)
5069        //        stem "exported_members" has NO matching production file (L1 miss)
5070        let dir = TempDir::new().unwrap();
5071        let pkg = dir.path().join("pkg");
5072        std::fs::create_dir_all(&pkg).unwrap();
5073        let tests_dir = dir.path().join("tests");
5074        std::fs::create_dir_all(&tests_dir).unwrap();
5075
5076        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5077        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5078        std::fs::write(
5079            pkg.join("__init__.py"),
5080            "from ._client import Client\nfrom ._utils import format_url\n",
5081        )
5082        .unwrap();
5083
5084        // bare `import pkg` with NO attribute access -> should fan-out via barrel (L1 miss)
5085        let test_content = "import pkg\n\ndef test_exported_members():\n    pass\n";
5086        std::fs::write(tests_dir.join("test_exported_members.py"), test_content).unwrap();
5087
5088        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5089        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5090        let test_path = tests_dir
5091            .join("test_exported_members.py")
5092            .to_string_lossy()
5093            .into_owned();
5094
5095        let extractor = PythonExtractor::new();
5096        let production_files = vec![client_path.clone(), utils_path.clone()];
5097        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5098            .into_iter()
5099            .collect();
5100
5101        // When: map_test_files_with_imports is called
5102        let result = extractor.map_test_files_with_imports(
5103            &production_files,
5104            &test_sources,
5105            dir.path(),
5106            false,
5107        );
5108
5109        // Then: barrel fan-out proceeds for L1-unmatched test
5110        //       BOTH _client.py and _utils.py should be mapped (barrel re-exports both)
5111        let client_mapped = result
5112            .iter()
5113            .find(|m| m.production_file == client_path)
5114            .map(|m| m.test_files.contains(&test_path))
5115            .unwrap_or(false);
5116        let utils_mapped = result
5117            .iter()
5118            .find(|m| m.production_file == utils_path)
5119            .map(|m| m.test_files.contains(&test_path))
5120            .unwrap_or(false);
5121
5122        assert!(
5123            client_mapped && utils_mapped,
5124            "L1-unmatched test should fan-out via barrel to both _client.py and _utils.py. client_mapped={}, utils_mapped={}, mappings={:?}",
5125            client_mapped,
5126            utils_mapped,
5127            result
5128        );
5129    }
5130
5131    // -----------------------------------------------------------------------
5132    // PY-SUP-04: E2E: httpx-like fixture demonstrates FP reduction (P >= 80%)
5133    //
5134    // Simulates the core httpx FP scenario:
5135    // - Multiple prod files under pkg/ with underscore prefix
5136    // - tests/ directory (different from pkg/)
5137    // - Some tests import pkg bare (no attribute access) -> currently fans-out to all
5138    // - stem-only fallback + barrel suppression should limit fan-out
5139    //
5140    // Note: P>=80% is the intermediate goal; Ship criteria is P>=98% (CONSTITUTION).
5141    // -----------------------------------------------------------------------
5142    #[test]
5143    fn py_sup_04_e2e_httpx_like_precision_improvement() {
5144        use tempfile::TempDir;
5145        use HashSet;
5146
5147        // Given: httpx-like structure
5148        //   pkg/_client.py, pkg/_decoders.py, pkg/_utils.py
5149        //   pkg/__init__.py: barrel re-exporting Client, decode, format_url
5150        //   tests/test_client.py: bare `import pkg` NO attribute access (stem -> _client.py)
5151        //   tests/test_decoders.py: bare `import pkg` NO attribute access (stem -> _decoders.py)
5152        //   tests/test_utils.py: bare `import pkg` NO attribute access (stem -> _utils.py)
5153        //   tests/test_exported_members.py: bare `import pkg` NO attr access (L1 miss -> barrel OK)
5154        let dir = TempDir::new().unwrap();
5155        let pkg = dir.path().join("pkg");
5156        std::fs::create_dir_all(&pkg).unwrap();
5157        let tests_dir = dir.path().join("tests");
5158        std::fs::create_dir_all(&tests_dir).unwrap();
5159
5160        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5161        std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
5162        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
5163        std::fs::write(
5164            pkg.join("__init__.py"),
5165            "from ._client import Client\nfrom ._decoders import decode\nfrom ._utils import format_url\n",
5166        )
5167        .unwrap();
5168
5169        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5170        let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
5171        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
5172        let production_files = vec![
5173            client_path.clone(),
5174            decoders_path.clone(),
5175            utils_path.clone(),
5176        ];
5177
5178        // All test files use bare `import pkg` with NO attribute access
5179        // -> without suppression: all fan-out to all 3 prod files (P=33%)
5180        // -> with stem-only + barrel suppression: each maps to 1 (P=100% for L1-matched)
5181        let test_client_content = "import pkg\n\ndef test_client():\n    pass\n";
5182        let test_decoders_content = "import pkg\n\ndef test_decode():\n    pass\n";
5183        let test_utils_content = "import pkg\n\ndef test_format_url():\n    pass\n";
5184        let test_exported_content = "import pkg\n\ndef test_exported_members():\n    pass\n";
5185
5186        let test_client_path = tests_dir
5187            .join("test_client.py")
5188            .to_string_lossy()
5189            .into_owned();
5190        let test_decoders_path = tests_dir
5191            .join("test_decoders.py")
5192            .to_string_lossy()
5193            .into_owned();
5194        let test_utils_path = tests_dir
5195            .join("test_utils.py")
5196            .to_string_lossy()
5197            .into_owned();
5198        let test_exported_path = tests_dir
5199            .join("test_exported_members.py")
5200            .to_string_lossy()
5201            .into_owned();
5202
5203        std::fs::write(&test_client_path, test_client_content).unwrap();
5204        std::fs::write(&test_decoders_path, test_decoders_content).unwrap();
5205        std::fs::write(&test_utils_path, test_utils_content).unwrap();
5206        std::fs::write(&test_exported_path, test_exported_content).unwrap();
5207
5208        let test_sources: HashMap<String, String> = [
5209            (test_client_path.clone(), test_client_content.to_string()),
5210            (
5211                test_decoders_path.clone(),
5212                test_decoders_content.to_string(),
5213            ),
5214            (test_utils_path.clone(), test_utils_content.to_string()),
5215            (
5216                test_exported_path.clone(),
5217                test_exported_content.to_string(),
5218            ),
5219        ]
5220        .into_iter()
5221        .collect();
5222
5223        let extractor = PythonExtractor::new();
5224
5225        // When: map_test_files_with_imports is called
5226        let result = extractor.map_test_files_with_imports(
5227            &production_files,
5228            &test_sources,
5229            dir.path(),
5230            false,
5231        );
5232
5233        // Ground truth (expected TP pairs):
5234        // test_client.py -> _client.py  (L1 stem-only)
5235        // test_decoders.py -> _decoders.py  (L1 stem-only)
5236        // test_utils.py -> _utils.py  (L1 stem-only)
5237        // test_exported_members.py -> _client.py, _decoders.py, _utils.py  (barrel, L1 miss)
5238        let ground_truth_set: HashSet<(String, String)> = [
5239            (test_client_path.clone(), client_path.clone()),
5240            (test_decoders_path.clone(), decoders_path.clone()),
5241            (test_utils_path.clone(), utils_path.clone()),
5242            (test_exported_path.clone(), client_path.clone()),
5243            (test_exported_path.clone(), decoders_path.clone()),
5244            (test_exported_path.clone(), utils_path.clone()),
5245        ]
5246        .into_iter()
5247        .collect();
5248
5249        let actual_pairs: HashSet<(String, String)> = result
5250            .iter()
5251            .flat_map(|m| {
5252                m.test_files
5253                    .iter()
5254                    .map(|t| (t.clone(), m.production_file.clone()))
5255                    .collect::<Vec<_>>()
5256            })
5257            .collect();
5258
5259        let tp = actual_pairs.intersection(&ground_truth_set).count();
5260        let fp = actual_pairs.difference(&ground_truth_set).count();
5261
5262        // Precision = TP / (TP + FP)
5263        let precision = if tp + fp == 0 {
5264            0.0
5265        } else {
5266            tp as f64 / (tp + fp) as f64
5267        };
5268
5269        // Then: precision >= 80% (intermediate goal)
5270        // Without stem-only + barrel suppression: all 4 tests fan-out to 3 prod files
5271        // = 12 pairs, but GT has 6 -> P = 6/12 = 50% (FAIL)
5272        // With suppression: 3 stem-matched tests -> 1 each + exported_members -> 3 = 6 pairs -> P = 100%
5273        assert!(
5274            precision >= 0.80,
5275            "Precision {:.1}% < 80% target. TP={}, FP={}, actual_pairs={:?}",
5276            precision * 100.0,
5277            tp,
5278            fp,
5279            actual_pairs
5280        );
5281    }
5282
5283    // -----------------------------------------------------------------------
5284    // PY-AF-01: assignment + assertion tracking
5285    //   `client = Client(); assert client.ok` -> Client in asserted_imports
5286    // -----------------------------------------------------------------------
5287    #[test]
5288    fn py_af_01_assert_via_assigned_var() {
5289        // Given: source where Client is assigned then asserted
5290        let source = r#"
5291from pkg.client import Client
5292
5293def test_something():
5294    client = Client()
5295    assert client.ok
5296"#;
5297        // When: extract_assertion_referenced_imports is called
5298        let result = extract_assertion_referenced_imports(source);
5299
5300        // Then: Client is in asserted_imports (assigned var `client` appears in assertion)
5301        assert!(
5302            result.contains("Client"),
5303            "Client should be in asserted_imports; got {:?}",
5304            result
5305        );
5306    }
5307
5308    // -----------------------------------------------------------------------
5309    // PY-AF-02: non-asserted assignment is excluded
5310    //   `transport = MockTransport()` (not in assert) -> MockTransport NOT in asserted_imports
5311    // -----------------------------------------------------------------------
5312    #[test]
5313    fn py_af_02_setup_only_import_excluded() {
5314        // Given: source where MockTransport is assigned but never asserted
5315        let source = r#"
5316from pkg.client import Client
5317from pkg.transport import MockTransport
5318
5319def test_something():
5320    transport = MockTransport()
5321    client = Client(transport=transport)
5322    assert client.ok
5323"#;
5324        // When: extract_assertion_referenced_imports is called
5325        let result = extract_assertion_referenced_imports(source);
5326
5327        // Then: MockTransport is NOT in asserted_imports (only used in setup)
5328        assert!(
5329            !result.contains("MockTransport"),
5330            "MockTransport should NOT be in asserted_imports (setup-only); got {:?}",
5331            result
5332        );
5333        // And: Client IS in asserted_imports (via chain: client -> Client)
5334        assert!(
5335            result.contains("Client"),
5336            "Client should be in asserted_imports; got {:?}",
5337            result
5338        );
5339    }
5340
5341    // -----------------------------------------------------------------------
5342    // PY-AF-03: direct usage in assertion
5343    //   `assert A() == B()` -> both A, B in asserted_imports
5344    // -----------------------------------------------------------------------
5345    #[test]
5346    fn py_af_03_direct_call_in_assertion() {
5347        // Given: source where two classes are directly called inside an assert
5348        let source = r#"
5349from pkg.models import A, B
5350
5351def test_equality():
5352    assert A() == B()
5353"#;
5354        // When: extract_assertion_referenced_imports is called
5355        let result = extract_assertion_referenced_imports(source);
5356
5357        // Then: both A and B are in asserted_imports
5358        assert!(
5359            result.contains("A"),
5360            "A should be in asserted_imports (used directly in assert); got {:?}",
5361            result
5362        );
5363        assert!(
5364            result.contains("B"),
5365            "B should be in asserted_imports (used directly in assert); got {:?}",
5366            result
5367        );
5368    }
5369
5370    // -----------------------------------------------------------------------
5371    // PY-AF-04: pytest.raises context
5372    //   `pytest.raises(HTTPError)` -> HTTPError in asserted_imports
5373    // -----------------------------------------------------------------------
5374    #[test]
5375    fn py_af_04_pytest_raises_captures_exception_class() {
5376        // Given: source using pytest.raises with an imported exception class
5377        let source = r#"
5378import pytest
5379from pkg.exceptions import HTTPError
5380
5381def test_raises():
5382    with pytest.raises(HTTPError):
5383        raise HTTPError("fail")
5384"#;
5385        // When: extract_assertion_referenced_imports is called
5386        let result = extract_assertion_referenced_imports(source);
5387
5388        // Then: HTTPError is in asserted_imports (appears in pytest.raises assertion node)
5389        assert!(
5390            result.contains("HTTPError"),
5391            "HTTPError should be in asserted_imports (pytest.raises arg); got {:?}",
5392            result
5393        );
5394    }
5395
5396    // -----------------------------------------------------------------------
5397    // PY-AF-05: chain tracking (2-hop)
5398    //   `response = client.get(); assert response.ok` -> Client reachable via chain
5399    //   client -> Client (1-hop), response -> client (2-hop through method call source)
5400    // -----------------------------------------------------------------------
5401    #[test]
5402    fn py_af_05_chain_tracking_two_hops() {
5403        // Given: source with a 2-hop chain: response derived from client, client from Client()
5404        let source = r#"
5405from pkg.client import Client
5406
5407def test_response():
5408    client = Client()
5409    response = client.get("http://example.com/")
5410    assert response.ok
5411"#;
5412        // When: extract_assertion_referenced_imports is called
5413        let result = extract_assertion_referenced_imports(source);
5414
5415        // Then: Client is reachable (response -> client -> Client, 2-hop chain)
5416        assert!(
5417            result.contains("Client"),
5418            "Client should be in asserted_imports via 2-hop chain; got {:?}",
5419            result
5420        );
5421    }
5422
5423    // -----------------------------------------------------------------------
5424    // PY-AF-06a: no assertions -> empty asserted_imports -> fallback to all_matched
5425    //   When assertion.scm detects no assertions, asserted_imports is empty
5426    //   and the caller falls back to all_matched.
5427    // -----------------------------------------------------------------------
5428    #[test]
5429    fn py_af_06a_no_assertions_returns_empty() {
5430        // Given: source with imports but zero assertion statements
5431        let source = r#"
5432from pkg.client import Client
5433from pkg.transport import MockTransport
5434
5435def test_setup_no_assert():
5436    client = Client()
5437    transport = MockTransport()
5438    # No assert statement at all
5439"#;
5440        // When: extract_assertion_referenced_imports is called
5441        let result = extract_assertion_referenced_imports(source);
5442
5443        // Then: asserted_imports is EMPTY (no assertions found, so no symbols traced)
5444        // The caller (map_test_files_with_imports) is responsible for the fallback.
5445        assert!(
5446            result.is_empty(),
5447            "expected empty asserted_imports when no assertions present; got {:?}",
5448            result
5449        );
5450    }
5451
5452    // -----------------------------------------------------------------------
5453    // PY-AF-06b: assertions exist but no asserted import intersects with L2 imports
5454    //   -> asserted_imports non-empty but does not overlap with any import symbol
5455    //   -> fallback to all_matched (safe side)
5456    // -----------------------------------------------------------------------
5457    #[test]
5458    fn py_af_06b_assertion_exists_but_no_import_intersection() {
5459        // Given: source where the assertion references a local variable (not an import)
5460        let source = r#"
5461from pkg.client import Client
5462
5463def test_local_only():
5464    local_value = 42
5465    # Assertion references only a local literal, not any imported symbol
5466    assert local_value == 42
5467"#;
5468        // When: extract_assertion_referenced_imports is called
5469        let result = extract_assertion_referenced_imports(source);
5470
5471        // Then: asserted_imports does NOT contain Client
5472        // (Client is never referenced inside an assertion node)
5473        assert!(
5474            !result.contains("Client"),
5475            "Client should NOT be in asserted_imports (not referenced in assertion); got {:?}",
5476            result
5477        );
5478        // Note: `result` may be empty or contain other identifiers from the assertion,
5479        // but the key property is that the imported symbol Client is absent.
5480    }
5481
5482    // -----------------------------------------------------------------------
5483    // PY-AF-07: unittest self.assert* form
5484    //   `self.assertEqual(result.value, 42)` -> result's import captured
5485    //   result = MyModel() -> MyModel in asserted_imports
5486    // -----------------------------------------------------------------------
5487    #[test]
5488    fn py_af_07_unittest_self_assert() {
5489        // Given: unittest-style test using self.assertEqual
5490        let source = r#"
5491import unittest
5492from pkg.models import MyModel
5493
5494class TestMyModel(unittest.TestCase):
5495    def test_value(self):
5496        result = MyModel()
5497        self.assertEqual(result.value, 42)
5498"#;
5499        // When: extract_assertion_referenced_imports is called
5500        let result = extract_assertion_referenced_imports(source);
5501
5502        // Then: MyModel is in asserted_imports (result -> MyModel, result in assertEqual)
5503        assert!(
5504            result.contains("MyModel"),
5505            "MyModel should be in asserted_imports via self.assertEqual; got {:?}",
5506            result
5507        );
5508    }
5509
5510    // -----------------------------------------------------------------------
5511    // PY-AF-08: E2E integration — primary import kept, incidental filtered
5512    //
5513    // Fixture: af_pkg/
5514    //   pkg/client.py    (Client class)   <- should be mapped
5515    //   pkg/transport.py (MockTransport)  <- should NOT be mapped (assertion filter)
5516    //   tests/test_client.py imports both, asserts only client.is_ok
5517    // -----------------------------------------------------------------------
5518    #[test]
5519    fn py_af_08_e2e_primary_kept_incidental_filtered() {
5520        use std::path::PathBuf;
5521        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5522            .parent()
5523            .unwrap()
5524            .parent()
5525            .unwrap()
5526            .join("tests/fixtures/python/observe/af_pkg");
5527
5528        let test_file = fixture_root
5529            .join("tests/test_client.py")
5530            .to_string_lossy()
5531            .into_owned();
5532        let client_prod = fixture_root
5533            .join("pkg/client.py")
5534            .to_string_lossy()
5535            .into_owned();
5536        let transport_prod = fixture_root
5537            .join("pkg/transport.py")
5538            .to_string_lossy()
5539            .into_owned();
5540
5541        let production_files = vec![client_prod.clone(), transport_prod.clone()];
5542        let test_source =
5543            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5544        let mut test_sources = HashMap::new();
5545        test_sources.insert(test_file.clone(), test_source);
5546
5547        // When: map_test_files_with_imports is called
5548        let extractor = PythonExtractor::new();
5549        let result = extractor.map_test_files_with_imports(
5550            &production_files,
5551            &test_sources,
5552            &fixture_root,
5553            false,
5554        );
5555
5556        // Then: test_client.py maps to client.py (Client is asserted)
5557        let client_mapping = result.iter().find(|m| m.production_file == client_prod);
5558        assert!(
5559            client_mapping.is_some(),
5560            "client.py should be in mappings; got {:?}",
5561            result
5562                .iter()
5563                .map(|m| &m.production_file)
5564                .collect::<Vec<_>>()
5565        );
5566        assert!(
5567            client_mapping.unwrap().test_files.contains(&test_file),
5568            "test_client.py should map to client.py"
5569        );
5570
5571        // And: test_client.py does NOT map to transport.py (MockTransport not asserted)
5572        let transport_mapping = result.iter().find(|m| m.production_file == transport_prod);
5573        let transport_maps_test = transport_mapping
5574            .map(|m| m.test_files.contains(&test_file))
5575            .unwrap_or(false);
5576        assert!(
5577            !transport_maps_test,
5578            "test_client.py should NOT map to transport.py (assertion filter); got {:?}",
5579            result
5580                .iter()
5581                .map(|m| (&m.production_file, &m.test_files))
5582                .collect::<Vec<_>>()
5583        );
5584    }
5585
5586    // -----------------------------------------------------------------------
5587    // PY-AF-09: E2E — ALL imports incidental -> fallback, no regression (FN prevented)
5588    //
5589    // Fixture: af_e2e_fallback/
5590    //   pkg/helpers.py (HelperA, HelperB)
5591    //   tests/test_helpers.py: imports both, assertion is about `result is None`
5592    //   -> asserted_matched would be empty -> fallback to all_matched
5593    //   -> helpers.py MUST appear in the mapping (no FN)
5594    // -----------------------------------------------------------------------
5595    #[test]
5596    fn py_af_09_e2e_all_incidental_fallback_no_fn() {
5597        use std::path::PathBuf;
5598        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5599            .parent()
5600            .unwrap()
5601            .parent()
5602            .unwrap()
5603            .join("tests/fixtures/python/observe/af_e2e_fallback");
5604
5605        let test_file = fixture_root
5606            .join("tests/test_helpers.py")
5607            .to_string_lossy()
5608            .into_owned();
5609        let helpers_prod = fixture_root
5610            .join("pkg/helpers.py")
5611            .to_string_lossy()
5612            .into_owned();
5613
5614        let production_files = vec![helpers_prod.clone()];
5615        let test_source =
5616            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5617        let mut test_sources = HashMap::new();
5618        test_sources.insert(test_file.clone(), test_source);
5619
5620        // When: map_test_files_with_imports is called
5621        let extractor = PythonExtractor::new();
5622        let result = extractor.map_test_files_with_imports(
5623            &production_files,
5624            &test_sources,
5625            &fixture_root,
5626            false,
5627        );
5628
5629        // Then: helpers.py IS mapped (fallback activated because asserted_matched is empty)
5630        let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
5631        assert!(
5632            helpers_mapping.is_some(),
5633            "helpers.py should be in mappings (fallback); got {:?}",
5634            result
5635                .iter()
5636                .map(|m| &m.production_file)
5637                .collect::<Vec<_>>()
5638        );
5639        assert!(
5640            helpers_mapping.unwrap().test_files.contains(&test_file),
5641            "test_helpers.py should map to helpers.py (fallback, no FN)"
5642        );
5643    }
5644
5645    // -----------------------------------------------------------------------
5646    // PY-AF-10: E2E — third_party_http_client pattern, FP reduction confirmed
5647    //
5648    // Fixture: af_e2e_http/
5649    //   pkg/http_client.py (HttpClient, HttpResponse) <- primary SUT
5650    //   pkg/exceptions.py  (RequestError)             <- incidental (pytest.raises)
5651    //   tests/test_http_client.py: asserts response.ok, response.status_code == 201
5652    //
5653    // HttpClient is reachable via chain (response -> client -> HttpClient).
5654    // exceptions.py: RequestError appears inside pytest.raises() which IS an
5655    // assertion node, so it will be in asserted_imports.
5656    // This test verifies http_client.py is always mapped (no FN on primary SUT).
5657    // -----------------------------------------------------------------------
5658    #[test]
5659    fn py_af_10_e2e_http_client_primary_mapped() {
5660        use std::path::PathBuf;
5661        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5662            .parent()
5663            .unwrap()
5664            .parent()
5665            .unwrap()
5666            .join("tests/fixtures/python/observe/af_e2e_http");
5667
5668        let test_file = fixture_root
5669            .join("tests/test_http_client.py")
5670            .to_string_lossy()
5671            .into_owned();
5672        let http_client_prod = fixture_root
5673            .join("pkg/http_client.py")
5674            .to_string_lossy()
5675            .into_owned();
5676        let exceptions_prod = fixture_root
5677            .join("pkg/exceptions.py")
5678            .to_string_lossy()
5679            .into_owned();
5680
5681        let production_files = vec![http_client_prod.clone(), exceptions_prod.clone()];
5682        let test_source =
5683            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
5684        let mut test_sources = HashMap::new();
5685        test_sources.insert(test_file.clone(), test_source);
5686
5687        // When: map_test_files_with_imports is called
5688        let extractor = PythonExtractor::new();
5689        let result = extractor.map_test_files_with_imports(
5690            &production_files,
5691            &test_sources,
5692            &fixture_root,
5693            false,
5694        );
5695
5696        // Then: http_client.py IS mapped (primary SUT, must not be a FN)
5697        let http_client_mapping = result
5698            .iter()
5699            .find(|m| m.production_file == http_client_prod);
5700        assert!(
5701            http_client_mapping.is_some(),
5702            "http_client.py should be in mappings; got {:?}",
5703            result
5704                .iter()
5705                .map(|m| &m.production_file)
5706                .collect::<Vec<_>>()
5707        );
5708        assert!(
5709            http_client_mapping.unwrap().test_files.contains(&test_file),
5710            "test_http_client.py should map to http_client.py (primary SUT)"
5711        );
5712    }
5713
5714    // -----------------------------------------------------------------------
5715    // PY-E2E-HELPER: test helper excluded from mappings
5716    // -----------------------------------------------------------------------
5717    #[test]
5718    fn py_e2e_helper_excluded_from_mappings() {
5719        // Given: tests/helpers.py is a test helper imported by tests/test_client.py
5720        //        pkg/client.py is the production SUT
5721        let tmp = tempfile::tempdir().unwrap();
5722        let root = tmp.path();
5723
5724        // Write fixture files
5725        let files: &[(&str, &str)] = &[
5726            ("pkg/__init__.py", ""),
5727            ("pkg/client.py", "class Client:\n    def connect(self):\n        return True\n"),
5728            ("tests/__init__.py", ""),
5729            ("tests/helpers.py", "def mock_client():\n    return \"mock\"\n"),
5730            (
5731                "tests/test_client.py",
5732                "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",
5733            ),
5734        ];
5735        for (rel, content) in files {
5736            let path = root.join(rel);
5737            if let Some(parent) = path.parent() {
5738                std::fs::create_dir_all(parent).unwrap();
5739            }
5740            std::fs::write(&path, content).unwrap();
5741        }
5742
5743        let extractor = PythonExtractor::new();
5744
5745        // production_files: pkg/client.py and tests/helpers.py
5746        // (discover_files would put helpers.py in production_files since it's not test_*.py)
5747        let client_abs = root.join("pkg/client.py").to_string_lossy().into_owned();
5748        let helpers_abs = root.join("tests/helpers.py").to_string_lossy().into_owned();
5749        let production_files = vec![client_abs.clone(), helpers_abs.clone()];
5750
5751        let test_abs = root
5752            .join("tests/test_client.py")
5753            .to_string_lossy()
5754            .into_owned();
5755        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";
5756        let test_sources: HashMap<String, String> = [(test_abs.clone(), test_content.to_string())]
5757            .into_iter()
5758            .collect();
5759
5760        // When: map_test_files_with_imports is called
5761        let mappings =
5762            extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5763
5764        // Then: tests/helpers.py should NOT appear as a production_file in any mapping
5765        for m in &mappings {
5766            assert!(
5767                !m.production_file.contains("helpers.py"),
5768                "helpers.py should be excluded as test helper, but found in mapping: {:?}",
5769                m
5770            );
5771        }
5772
5773        // Then: pkg/client.py SHOULD be mapped to test_client.py
5774        let client_mapping = mappings
5775            .iter()
5776            .find(|m| m.production_file.contains("client.py"));
5777        assert!(
5778            client_mapping.is_some(),
5779            "pkg/client.py should be mapped; got {:?}",
5780            mappings
5781                .iter()
5782                .map(|m| &m.production_file)
5783                .collect::<Vec<_>>()
5784        );
5785        let client_mapping = client_mapping.unwrap();
5786        assert!(
5787            client_mapping
5788                .test_files
5789                .iter()
5790                .any(|t| t.contains("test_client.py")),
5791            "pkg/client.py should map to test_client.py; got {:?}",
5792            client_mapping.test_files
5793        );
5794    }
5795
5796    // -----------------------------------------------------------------------
5797    // PY-FP-01: MockTransport fixture re-exported via barrel should NOT be mapped.
5798    //
5799    // is_non_sut_helper excludes mock*.py files from production_files.
5800    // -----------------------------------------------------------------------
5801    #[test]
5802    fn py_fp_01_mock_transport_fixture_not_mapped() {
5803        use tempfile::TempDir;
5804
5805        let dir = TempDir::new().unwrap();
5806        let root = dir.path();
5807        let pkg = root.join("pkg");
5808        let transports = pkg.join("_transports");
5809        let tests_dir = root.join("tests");
5810        std::fs::create_dir_all(&transports).unwrap();
5811        std::fs::create_dir_all(&tests_dir).unwrap();
5812
5813        std::fs::write(
5814            transports.join("mock.py"),
5815            "class MockTransport:\n    pass\n",
5816        )
5817        .unwrap();
5818        std::fs::write(
5819            transports.join("__init__.py"),
5820            "from .mock import MockTransport\n",
5821        )
5822        .unwrap();
5823        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5824        std::fs::write(
5825            pkg.join("__init__.py"),
5826            "from ._transports import *\nfrom ._client import Client\n",
5827        )
5828        .unwrap();
5829
5830        let test_content = "import pkg\n\ndef test_hooks():\n    client = pkg.Client(transport=pkg.MockTransport())\n    assert client is not None\n";
5831        std::fs::write(tests_dir.join("test_hooks.py"), test_content).unwrap();
5832
5833        let mock_path = transports.join("mock.py").to_string_lossy().into_owned();
5834        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5835        let test_path = tests_dir
5836            .join("test_hooks.py")
5837            .to_string_lossy()
5838            .into_owned();
5839
5840        let extractor = PythonExtractor::new();
5841        let production_files = vec![mock_path.clone(), client_path.clone()];
5842        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5843            .into_iter()
5844            .collect();
5845
5846        let result =
5847            extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5848
5849        let mock_mapping = result.iter().find(|m| m.production_file == mock_path);
5850        assert!(
5851            mock_mapping.is_none() || mock_mapping.unwrap().test_files.is_empty(),
5852            "mock.py should NOT be mapped (fixture); mappings={:?}",
5853            result
5854        );
5855    }
5856
5857    // -----------------------------------------------------------------------
5858    // PY-FP-02: __version__.py incidental should NOT be mapped.
5859    //
5860    // is_non_sut_helper excludes __version__.py from production_files.
5861    // -----------------------------------------------------------------------
5862    #[test]
5863    fn py_fp_02_version_py_incidental_not_mapped() {
5864        use tempfile::TempDir;
5865
5866        let dir = TempDir::new().unwrap();
5867        let root = dir.path();
5868        let pkg = root.join("pkg");
5869        let tests_dir = root.join("tests");
5870        std::fs::create_dir_all(&pkg).unwrap();
5871        std::fs::create_dir_all(&tests_dir).unwrap();
5872
5873        std::fs::write(pkg.join("__version__.py"), "__version__ = \"1.0.0\"\n").unwrap();
5874        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5875        std::fs::write(
5876            pkg.join("__init__.py"),
5877            "from .__version__ import __version__\nfrom ._client import Client\n",
5878        )
5879        .unwrap();
5880
5881        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";
5882        std::fs::write(tests_dir.join("test_headers.py"), test_content).unwrap();
5883
5884        let version_path = pkg.join("__version__.py").to_string_lossy().into_owned();
5885        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5886        let test_path = tests_dir
5887            .join("test_headers.py")
5888            .to_string_lossy()
5889            .into_owned();
5890
5891        let extractor = PythonExtractor::new();
5892        let production_files = vec![version_path.clone(), client_path.clone()];
5893        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5894            .into_iter()
5895            .collect();
5896
5897        let result =
5898            extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5899
5900        let version_mapping = result.iter().find(|m| m.production_file == version_path);
5901        assert!(
5902            version_mapping.is_none() || version_mapping.unwrap().test_files.is_empty(),
5903            "__version__.py should NOT be mapped (metadata); mappings={:?}",
5904            result
5905        );
5906    }
5907
5908    // -----------------------------------------------------------------------
5909    // PY-FP-03: _types.py type-annotation-only should NOT be mapped.
5910    //
5911    // is_non_sut_helper excludes _types.py from production_files.
5912    // -----------------------------------------------------------------------
5913    #[test]
5914    fn py_fp_03_types_py_annotation_not_mapped() {
5915        use tempfile::TempDir;
5916
5917        let dir = TempDir::new().unwrap();
5918        let root = dir.path();
5919        let pkg = root.join("pkg");
5920        let tests_dir = root.join("tests");
5921        std::fs::create_dir_all(&pkg).unwrap();
5922        std::fs::create_dir_all(&tests_dir).unwrap();
5923
5924        std::fs::write(
5925            pkg.join("_types.py"),
5926            "from typing import Union\nQueryParamTypes = Union[str, dict]\n",
5927        )
5928        .unwrap();
5929        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5930        std::fs::write(
5931            pkg.join("__init__.py"),
5932            "from ._types import *\nfrom ._client import Client\n",
5933        )
5934        .unwrap();
5935
5936        let test_content = "import pkg\n\ndef test_client():\n    client = pkg.Client()\n    assert client is not None\n";
5937        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5938
5939        let types_path = pkg.join("_types.py").to_string_lossy().into_owned();
5940        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5941        let test_path = tests_dir
5942            .join("test_client.py")
5943            .to_string_lossy()
5944            .into_owned();
5945
5946        let extractor = PythonExtractor::new();
5947        let production_files = vec![types_path.clone(), client_path.clone()];
5948        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5949            .into_iter()
5950            .collect();
5951
5952        let result =
5953            extractor.map_test_files_with_imports(&production_files, &test_sources, root, false);
5954
5955        let types_mapping = result.iter().find(|m| m.production_file == types_path);
5956        assert!(
5957            types_mapping.is_none() || types_mapping.unwrap().test_files.is_empty(),
5958            "_types.py should NOT be mapped (type definitions); mappings={:?}",
5959            result
5960        );
5961    }
5962
5963    // -----------------------------------------------------------------------
5964    // PY-RESOLVE-PRIORITY-01: file wins over package when both exist
5965    // -----------------------------------------------------------------------
5966    #[test]
5967    fn py_resolve_priority_01_file_wins_over_package() {
5968        // Given: tempdir with both foo/bar/baz.py and foo/bar/baz/__init__.py
5969        let tmp = tempfile::tempdir().unwrap();
5970        let baz_dir = tmp.path().join("foo").join("bar").join("baz");
5971        std::fs::create_dir_all(&baz_dir).unwrap();
5972        let baz_file = tmp.path().join("foo").join("bar").join("baz.py");
5973        std::fs::write(&baz_file, "class Baz: pass\n").unwrap();
5974        let baz_init = baz_dir.join("__init__.py");
5975        std::fs::write(&baz_init, "from .impl import Baz\n").unwrap();
5976
5977        let canonical_root = tmp.path().canonicalize().unwrap();
5978        let base = tmp.path().join("foo").join("bar").join("baz");
5979        let extractor = PythonExtractor::new();
5980
5981        // When: resolve_absolute_base_to_file is called
5982        let result =
5983            exspec_core::observe::resolve_absolute_base_to_file(&extractor, &base, &canonical_root);
5984
5985        // Then: resolves to baz.py (file), not baz/__init__.py (package)
5986        assert!(result.is_some(), "expected resolution, got None");
5987        let resolved = result.unwrap();
5988        assert!(
5989            resolved.ends_with("baz.py"),
5990            "expected baz.py (file wins over package), got: {resolved}"
5991        );
5992        assert!(
5993            !resolved.contains("__init__"),
5994            "should NOT resolve to __init__.py, got: {resolved}"
5995        );
5996    }
5997
5998    // -----------------------------------------------------------------------
5999    // PY-SUBMOD-01: direct import + barrel coexist, assertion filter bypass
6000    //
6001    // `from pkg._urlparse import normalize` is a direct import to _urlparse.py.
6002    // Even though `normalize` does not appear in `assert pkg.URL(...)`,
6003    // _urlparse.py SHOULD be mapped because it was directly imported (not via barrel).
6004    // -----------------------------------------------------------------------
6005    #[test]
6006    fn py_submod_01_direct_import_bypasses_assertion_filter() {
6007        use std::collections::HashMap;
6008        use tempfile::TempDir;
6009
6010        // Given: pkg/_urlparse.py, pkg/_client.py, pkg/__init__.py (re-exports _client only)
6011        //        tests/test_whatwg.py:
6012        //          from pkg._urlparse import normalize   <- direct import (not in assertion)
6013        //          import pkg                            <- barrel import
6014        //          def test_url():
6015        //              assert pkg.URL("http://example.com")  <- assertion uses URL (from _client)
6016        let dir = TempDir::new().unwrap();
6017        let pkg = dir.path().join("pkg");
6018        std::fs::create_dir_all(&pkg).unwrap();
6019        let tests_dir = dir.path().join("tests");
6020        std::fs::create_dir_all(&tests_dir).unwrap();
6021
6022        std::fs::write(pkg.join("_urlparse.py"), "def normalize(url): return url\n").unwrap();
6023        std::fs::write(pkg.join("_client.py"), "class URL:\n    pass\n").unwrap();
6024        // __init__.py re-exports _client only (NOT _urlparse)
6025        std::fs::write(pkg.join("__init__.py"), "from ._client import URL\n").unwrap();
6026
6027        let test_content = "from pkg._urlparse import normalize\nimport pkg\n\ndef test_url():\n    assert pkg.URL(\"http://example.com\")\n";
6028        std::fs::write(tests_dir.join("test_whatwg.py"), test_content).unwrap();
6029
6030        let urlparse_path = pkg.join("_urlparse.py").to_string_lossy().into_owned();
6031        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6032        let test_path = tests_dir
6033            .join("test_whatwg.py")
6034            .to_string_lossy()
6035            .into_owned();
6036
6037        let extractor = PythonExtractor::new();
6038        let production_files = vec![urlparse_path.clone(), client_path.clone()];
6039        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6040            .into_iter()
6041            .collect();
6042
6043        // When: map_test_files_with_imports is called
6044        let result = extractor.map_test_files_with_imports(
6045            &production_files,
6046            &test_sources,
6047            dir.path(),
6048            false,
6049        );
6050
6051        // Then: _urlparse.py IS mapped (direct import bypasses assertion filter)
6052        let urlparse_mapped = result
6053            .iter()
6054            .find(|m| m.production_file == urlparse_path)
6055            .map(|m| m.test_files.contains(&test_path))
6056            .unwrap_or(false);
6057        assert!(
6058            urlparse_mapped,
6059            "pkg/_urlparse.py should be mapped via direct import (assertion filter bypass). mappings={:?}",
6060            result
6061        );
6062    }
6063
6064    // -----------------------------------------------------------------------
6065    // PY-SUBMOD-02: un-re-exported sub-module with direct import
6066    //
6067    // `from pkg._internal import helper` imports a module that is NOT in __init__.py.
6068    // Since it is directly imported and helper() appears in assertion, _internal.py SHOULD be mapped.
6069    // -----------------------------------------------------------------------
6070    #[test]
6071    fn py_submod_02_unre_exported_direct_import_mapped() {
6072        use std::collections::HashMap;
6073        use tempfile::TempDir;
6074
6075        // Given: pkg/_internal.py (NOT in __init__.py), tests/test_internal.py:
6076        //          from pkg._internal import helper
6077        //          def test_it():
6078        //              assert helper()
6079        let dir = TempDir::new().unwrap();
6080        let pkg = dir.path().join("pkg");
6081        std::fs::create_dir_all(&pkg).unwrap();
6082        let tests_dir = dir.path().join("tests");
6083        std::fs::create_dir_all(&tests_dir).unwrap();
6084
6085        std::fs::write(pkg.join("_internal.py"), "def helper(): return True\n").unwrap();
6086        // __init__.py does NOT re-export _internal
6087        std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
6088
6089        let test_content =
6090            "from pkg._internal import helper\n\ndef test_it():\n    assert helper()\n";
6091        std::fs::write(tests_dir.join("test_internal.py"), test_content).unwrap();
6092
6093        let internal_path = pkg.join("_internal.py").to_string_lossy().into_owned();
6094        let test_path = tests_dir
6095            .join("test_internal.py")
6096            .to_string_lossy()
6097            .into_owned();
6098
6099        let extractor = PythonExtractor::new();
6100        let production_files = vec![internal_path.clone()];
6101        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6102            .into_iter()
6103            .collect();
6104
6105        // When: map_test_files_with_imports is called
6106        let result = extractor.map_test_files_with_imports(
6107            &production_files,
6108            &test_sources,
6109            dir.path(),
6110            false,
6111        );
6112
6113        // Then: _internal.py IS mapped
6114        let internal_mapped = result
6115            .iter()
6116            .find(|m| m.production_file == internal_path)
6117            .map(|m| m.test_files.contains(&test_path))
6118            .unwrap_or(false);
6119        assert!(
6120            internal_mapped,
6121            "pkg/_internal.py should be mapped via direct import. mappings={:?}",
6122            result
6123        );
6124    }
6125
6126    // -----------------------------------------------------------------------
6127    // PY-SUBMOD-03: nested sub-module direct import
6128    //
6129    // `from pkg._internal._helpers import util` imports a nested sub-module.
6130    // _helpers.py SHOULD be mapped.
6131    // -----------------------------------------------------------------------
6132    #[test]
6133    fn py_submod_03_nested_submodule_direct_import_mapped() {
6134        use std::collections::HashMap;
6135        use tempfile::TempDir;
6136
6137        // Given: pkg/_internal/_helpers.py, tests/test_helpers.py:
6138        //          from pkg._internal._helpers import util
6139        //          def test_util():
6140        //              assert util()
6141        let dir = TempDir::new().unwrap();
6142        let pkg = dir.path().join("pkg");
6143        let internal = pkg.join("_internal");
6144        std::fs::create_dir_all(&internal).unwrap();
6145        let tests_dir = dir.path().join("tests");
6146        std::fs::create_dir_all(&tests_dir).unwrap();
6147
6148        std::fs::write(internal.join("_helpers.py"), "def util(): return True\n").unwrap();
6149        std::fs::write(internal.join("__init__.py"), "# empty\n").unwrap();
6150        std::fs::write(pkg.join("__init__.py"), "# empty barrel\n").unwrap();
6151
6152        let test_content =
6153            "from pkg._internal._helpers import util\n\ndef test_util():\n    assert util()\n";
6154        std::fs::write(tests_dir.join("test_helpers.py"), test_content).unwrap();
6155
6156        let helpers_path = internal.join("_helpers.py").to_string_lossy().into_owned();
6157        let test_path = tests_dir
6158            .join("test_helpers.py")
6159            .to_string_lossy()
6160            .into_owned();
6161
6162        let extractor = PythonExtractor::new();
6163        let production_files = vec![helpers_path.clone()];
6164        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6165            .into_iter()
6166            .collect();
6167
6168        // When: map_test_files_with_imports is called
6169        let result = extractor.map_test_files_with_imports(
6170            &production_files,
6171            &test_sources,
6172            dir.path(),
6173            false,
6174        );
6175
6176        // Then: _helpers.py IS mapped
6177        let helpers_mapped = result
6178            .iter()
6179            .find(|m| m.production_file == helpers_path)
6180            .map(|m| m.test_files.contains(&test_path))
6181            .unwrap_or(false);
6182        assert!(
6183            helpers_mapped,
6184            "pkg/_internal/_helpers.py should be mapped via nested direct import. mappings={:?}",
6185            result
6186        );
6187    }
6188
6189    // -----------------------------------------------------------------------
6190    // PY-SUBMOD-05: non-bare relative direct import bypass
6191    //
6192    // `from ._config import Config` is a non-bare relative direct import.
6193    // Even though `Config` does not appear in assertions (only `Client` is
6194    // asserted, which comes from the barrel), _config.py SHOULD be mapped
6195    // because direct_import_indices bypass the assertion filter.
6196    // (Fixed in #146: relative import branches now populate direct_import_indices)
6197    // -----------------------------------------------------------------------
6198    #[test]
6199    fn py_submod_05_non_bare_relative_direct_import_bypass() {
6200        use std::collections::HashMap;
6201        use tempfile::TempDir;
6202
6203        // Given: pkg/_config.py (non-barrel production file, has Config)
6204        //        pkg/_client.py (non-barrel production file, has Client)
6205        //        pkg/__init__.py (barrel: re-exports Client from ._client)
6206        //        pkg/test_app.py (stem "app", no L1 match to _config or _client):
6207        //          import pkg                    <- barrel import
6208        //          from ._config import Config   <- non-bare relative direct import
6209        //          def test_something():
6210        //              assert pkg.Client()       <- assertion uses Client (from _client),
6211        //                                           NOT Config (from _config)
6212        //
6213        // Key: test file is named "test_app.py" (stem "app") so L1 stem matching
6214        //      does NOT match _config.py (stem "config") or _client.py (stem "client").
6215        let dir = TempDir::new().unwrap();
6216        let pkg = dir.path().join("pkg");
6217        std::fs::create_dir_all(&pkg).unwrap();
6218
6219        std::fs::write(pkg.join("_config.py"), "class Config:\n    pass\n").unwrap();
6220        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
6221        // __init__.py re-exports Client (NOT Config)
6222        std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
6223
6224        let test_content = "import pkg\nfrom ._config import Config\n\ndef test_something():\n    assert pkg.Client()\n";
6225        std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
6226
6227        let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
6228        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6229        let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
6230
6231        let extractor = PythonExtractor::new();
6232        let production_files = vec![config_path.clone(), client_path.clone()];
6233        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6234            .into_iter()
6235            .collect();
6236
6237        // When: map_test_files_with_imports is called
6238        let result = extractor.map_test_files_with_imports(
6239            &production_files,
6240            &test_sources,
6241            dir.path(),
6242            false,
6243        );
6244
6245        // Then: _config.py IS mapped (non-bare relative direct import bypasses assertion filter)
6246        let config_mapped = result
6247            .iter()
6248            .find(|m| m.production_file == config_path)
6249            .map(|m| m.test_files.contains(&test_path))
6250            .unwrap_or(false);
6251        assert!(
6252            config_mapped,
6253            "pkg/_config.py should be mapped via non-bare relative direct import (assertion filter bypass). mappings={:?}",
6254            result
6255        );
6256    }
6257
6258    // -----------------------------------------------------------------------
6259    // PY-SUBMOD-06: bare relative direct import bypass
6260    //
6261    // `from . import utils` is a bare relative direct import.
6262    // Even though `utils` does not appear in assertions (only `Client` is
6263    // asserted, which comes from the barrel), utils.py SHOULD be mapped
6264    // because direct_import_indices bypass the assertion filter.
6265    // (Fixed in #146: relative import branches now populate direct_import_indices)
6266    // -----------------------------------------------------------------------
6267    #[test]
6268    fn py_submod_06_bare_relative_direct_import_bypass() {
6269        use std::collections::HashMap;
6270        use tempfile::TempDir;
6271
6272        // Given: pkg/utils.py (non-barrel production file, has helper)
6273        //        pkg/_client.py (non-barrel production file, has Client)
6274        //        pkg/__init__.py (barrel: re-exports Client from ._client)
6275        //        pkg/test_app.py (stem "app", no L1 match to utils or _client):
6276        //          import pkg              <- barrel import
6277        //          from . import utils     <- bare relative direct import
6278        //          def test_something():
6279        //              assert pkg.Client() <- assertion uses Client (from _client),
6280        //                                    NOT utils.helper
6281        //
6282        // Key: test file is named "test_app.py" (stem "app") so L1 stem matching
6283        //      does NOT match utils.py (stem "utils") or _client.py (stem "client").
6284        let dir = TempDir::new().unwrap();
6285        let pkg = dir.path().join("pkg");
6286        std::fs::create_dir_all(&pkg).unwrap();
6287
6288        std::fs::write(pkg.join("utils.py"), "def helper(): return True\n").unwrap();
6289        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
6290        // __init__.py re-exports Client (NOT utils)
6291        std::fs::write(pkg.join("__init__.py"), "from ._client import Client\n").unwrap();
6292
6293        let test_content =
6294            "import pkg\nfrom . import utils\n\ndef test_something():\n    assert pkg.Client()\n";
6295        std::fs::write(pkg.join("test_app.py"), test_content).unwrap();
6296
6297        let utils_path = pkg.join("utils.py").to_string_lossy().into_owned();
6298        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
6299        let test_path = pkg.join("test_app.py").to_string_lossy().into_owned();
6300
6301        let extractor = PythonExtractor::new();
6302        let production_files = vec![utils_path.clone(), client_path.clone()];
6303        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6304            .into_iter()
6305            .collect();
6306
6307        // When: map_test_files_with_imports is called
6308        let result = extractor.map_test_files_with_imports(
6309            &production_files,
6310            &test_sources,
6311            dir.path(),
6312            false,
6313        );
6314
6315        // Then: utils.py IS mapped (bare relative direct import bypasses assertion filter)
6316        let utils_mapped = result
6317            .iter()
6318            .find(|m| m.production_file == utils_path)
6319            .map(|m| m.test_files.contains(&test_path))
6320            .unwrap_or(false);
6321        assert!(
6322            utils_mapped,
6323            "pkg/utils.py should be mapped via bare relative direct import (assertion filter bypass). mappings={:?}",
6324            result
6325        );
6326    }
6327
6328    // -----------------------------------------------------------------------
6329    // PY-SUBMOD-04: regression — barrel-only import still filtered by assertion
6330    //
6331    // `import pkg` + `assert pkg.Config()` → _config.py IS mapped.
6332    // `import pkg` + no assertion on Model → _models.py is NOT mapped.
6333    // Assertion filter continues to work for barrel imports.
6334    // -----------------------------------------------------------------------
6335    #[test]
6336    fn py_submod_04_regression_barrel_only_assertion_filter_preserved() {
6337        use std::collections::HashMap;
6338        use tempfile::TempDir;
6339
6340        // Given: pkg/_config.py, pkg/_models.py, pkg/__init__.py (re-exports both)
6341        //        tests/test_foo.py:
6342        //          import pkg
6343        //          def test_foo():
6344        //              assert pkg.Config()   <- assertion uses Config (from _config.py)
6345        //                                   <- no assertion on Model (from _models.py)
6346        let dir = TempDir::new().unwrap();
6347        let pkg = dir.path().join("pkg");
6348        std::fs::create_dir_all(&pkg).unwrap();
6349        let tests_dir = dir.path().join("tests");
6350        std::fs::create_dir_all(&tests_dir).unwrap();
6351
6352        std::fs::write(pkg.join("_config.py"), "class Config:\n    pass\n").unwrap();
6353        std::fs::write(pkg.join("_models.py"), "class Model:\n    pass\n").unwrap();
6354        // __init__.py re-exports both
6355        std::fs::write(
6356            pkg.join("__init__.py"),
6357            "from ._config import Config\nfrom ._models import Model\n",
6358        )
6359        .unwrap();
6360
6361        let test_content = "import pkg\n\ndef test_foo():\n    assert pkg.Config()\n";
6362        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
6363
6364        let config_path = pkg.join("_config.py").to_string_lossy().into_owned();
6365        let models_path = pkg.join("_models.py").to_string_lossy().into_owned();
6366        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
6367
6368        let extractor = PythonExtractor::new();
6369        let production_files = vec![config_path.clone(), models_path.clone()];
6370        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
6371            .into_iter()
6372            .collect();
6373
6374        // When: map_test_files_with_imports is called
6375        let result = extractor.map_test_files_with_imports(
6376            &production_files,
6377            &test_sources,
6378            dir.path(),
6379            false,
6380        );
6381
6382        // Then: _models.py is NOT mapped (barrel import, assertion filter still applies)
6383        let models_not_mapped = result
6384            .iter()
6385            .find(|m| m.production_file == models_path)
6386            .map(|m| !m.test_files.contains(&test_path))
6387            .unwrap_or(true);
6388        assert!(
6389            models_not_mapped,
6390            "pkg/_models.py should NOT be mapped (barrel import, no assertion on Model). mappings={:?}",
6391            result
6392        );
6393    }
6394}