Skip to main content

exspec_lang_python/
observe.rs

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