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    None
63}
64
65/// Extract stem from a production file path.
66/// `user.py` -> `Some("user")`
67/// `_decoders.py` -> `Some("decoders")` (leading `_` stripped)
68/// `__init__.py` -> `None`
69/// `test_user.py` -> `None`
70pub fn production_stem(path: &str) -> Option<&str> {
71    let file_name = Path::new(path).file_name()?.to_str()?;
72    let stem = file_name.strip_suffix(".py")?;
73    // Exclude __init__.py
74    if stem == "__init__" {
75        return None;
76    }
77    // Exclude test files
78    if stem.starts_with("test_") || stem.ends_with("_test") {
79        return None;
80    }
81    let stem = stem.strip_prefix('_').unwrap_or(stem);
82    let stem = stem.strip_suffix("__").unwrap_or(stem);
83    Some(stem)
84}
85
86/// Determine if a file is a non-SUT helper (should be excluded from mapping).
87pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
88    // Phase 20: Path-segment check BEFORE is_known_production bypass.
89    // Files inside tests/ or test/ directories that are NOT test files
90    // are always helpers, even if they appear in production_files list.
91    // (Same pattern as TypeScript observe.)
92    let in_test_dir = file_path
93        .split('/')
94        .any(|seg| seg == "tests" || seg == "test");
95
96    if in_test_dir {
97        return true;
98    }
99
100    // Phase 21: Metadata/fixture/type-only files are always non-SUT helpers,
101    // even if they appear in production_files list.
102    // These files are frequently re-exported via barrels and cause FP fan-out.
103    let stem_only = Path::new(file_path)
104        .file_stem()
105        .and_then(|f| f.to_str())
106        .unwrap_or("");
107
108    // __version__.py: package metadata, not a SUT
109    if stem_only == "__version__" {
110        return true;
111    }
112
113    // _types.py / __types__.py: pure type-definition files
114    {
115        let normalized = stem_only.trim_matches('_');
116        if normalized == "types" || normalized.ends_with("_types") {
117            return true;
118        }
119    }
120
121    // mock.py / mock_*.py: test fixture/infrastructure
122    if stem_only == "mock" || stem_only.starts_with("mock_") {
123        return true;
124    }
125
126    if is_known_production {
127        return false;
128    }
129
130    let file_name = Path::new(file_path)
131        .file_name()
132        .and_then(|f| f.to_str())
133        .unwrap_or("");
134
135    // Known helper filenames
136    if matches!(
137        file_name,
138        "conftest.py" | "constants.py" | "setup.py" | "__init__.py"
139    ) {
140        return true;
141    }
142
143    // __pycache__/ files are helpers
144    let parent_is_pycache = Path::new(file_path)
145        .parent()
146        .and_then(|p| p.file_name())
147        .and_then(|f| f.to_str())
148        .map(|s| s == "__pycache__")
149        .unwrap_or(false);
150
151    if parent_is_pycache {
152        return true;
153    }
154
155    false
156}
157
158// ---------------------------------------------------------------------------
159// Standalone helpers
160// ---------------------------------------------------------------------------
161
162/// Extract attribute names accessed on a bare-imported module.
163///
164/// For `import httpx; httpx.Client(); httpx.get()`, returns `["Client", "get"]`.
165/// Returns empty vec if no attribute accesses are found (fallback to full match).
166fn extract_bare_import_attributes(
167    source_bytes: &[u8],
168    tree: &tree_sitter::Tree,
169    module_name: &str,
170) -> Vec<String> {
171    let query = cached_query(
172        &BARE_IMPORT_ATTRIBUTE_QUERY_CACHE,
173        BARE_IMPORT_ATTRIBUTE_QUERY,
174    );
175    let module_name_idx = query.capture_index_for_name("module_name").unwrap();
176    let attribute_name_idx = query.capture_index_for_name("attribute_name").unwrap();
177
178    let mut cursor = QueryCursor::new();
179    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
180
181    let mut attrs: Vec<String> = Vec::new();
182    while let Some(m) = matches.next() {
183        let mut mod_text = "";
184        let mut attr_text = "";
185        for cap in m.captures {
186            if cap.index == module_name_idx {
187                mod_text = cap.node.utf8_text(source_bytes).unwrap_or("");
188            } else if cap.index == attribute_name_idx {
189                attr_text = cap.node.utf8_text(source_bytes).unwrap_or("");
190            }
191        }
192        if mod_text == module_name && !attr_text.is_empty() {
193            attrs.push(attr_text.to_string());
194        }
195    }
196    attrs.sort();
197    attrs.dedup();
198    attrs
199}
200
201// ---------------------------------------------------------------------------
202// ObserveExtractor impl
203// ---------------------------------------------------------------------------
204
205impl ObserveExtractor for PythonExtractor {
206    fn extract_production_functions(
207        &self,
208        source: &str,
209        file_path: &str,
210    ) -> Vec<ProductionFunction> {
211        let mut parser = Self::parser();
212        let tree = match parser.parse(source, None) {
213            Some(t) => t,
214            None => return Vec::new(),
215        };
216        let source_bytes = source.as_bytes();
217        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
218
219        // Capture indices
220        let name_idx = query.capture_index_for_name("name");
221        let class_name_idx = query.capture_index_for_name("class_name");
222        let method_name_idx = query.capture_index_for_name("method_name");
223        let decorated_name_idx = query.capture_index_for_name("decorated_name");
224        let decorated_class_name_idx = query.capture_index_for_name("decorated_class_name");
225        let decorated_method_name_idx = query.capture_index_for_name("decorated_method_name");
226
227        // Indices that represent function names (any of these → fn_name)
228        let fn_name_indices: [Option<u32>; 4] = [
229            name_idx,
230            method_name_idx,
231            decorated_name_idx,
232            decorated_method_name_idx,
233        ];
234        // Indices that represent class names (any of these → class_name)
235        let class_name_indices: [Option<u32>; 2] = [class_name_idx, decorated_class_name_idx];
236
237        let mut cursor = QueryCursor::new();
238        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
239        let mut result = Vec::new();
240
241        while let Some(m) = matches.next() {
242            // Determine which pattern matched based on captures present
243            let mut fn_name: Option<String> = None;
244            let mut class_name: Option<String> = None;
245            let mut line: usize = 1;
246
247            for cap in m.captures {
248                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
249                let node_line = cap.node.start_position().row + 1;
250
251                if fn_name_indices.contains(&Some(cap.index)) {
252                    fn_name = Some(text);
253                    line = node_line;
254                } else if class_name_indices.contains(&Some(cap.index)) {
255                    class_name = Some(text);
256                }
257            }
258
259            if let Some(name) = fn_name {
260                result.push(ProductionFunction {
261                    name,
262                    file: file_path.to_string(),
263                    line,
264                    class_name,
265                    is_exported: true,
266                });
267            }
268        }
269
270        // Deduplicate: same name + class_name pair may appear from multiple patterns
271        let mut seen = HashSet::new();
272        result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
273
274        result
275    }
276
277    fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
278        let mut parser = Self::parser();
279        let tree = match parser.parse(source, None) {
280            Some(t) => t,
281            None => return Vec::new(),
282        };
283        let source_bytes = source.as_bytes();
284        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
285
286        let module_name_idx = query.capture_index_for_name("module_name");
287        let symbol_name_idx = query.capture_index_for_name("symbol_name");
288
289        let mut cursor = QueryCursor::new();
290        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
291
292        // Collect raw (module_text, symbol_text) pairs
293        let mut raw: Vec<(String, String, usize)> = Vec::new();
294
295        while let Some(m) = matches.next() {
296            let mut module_text: Option<String> = None;
297            let mut symbol_text: Option<String> = None;
298            let mut symbol_line: usize = 1;
299
300            for cap in m.captures {
301                if module_name_idx == Some(cap.index) {
302                    module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
303                } else if symbol_name_idx == Some(cap.index) {
304                    symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
305                    symbol_line = cap.node.start_position().row + 1;
306                }
307            }
308
309            let (module_text, symbol_text) = match (module_text, symbol_text) {
310                (Some(m), Some(s)) => (m, s),
311                _ => continue,
312            };
313
314            // Convert Python module path to specifier:
315            // Leading dots: `.` -> `./`, `..` -> `../`, etc.
316            // `from .models import X`  -> module_text might be ".models" or "models" depending on parse
317            // We need to handle tree-sitter-python's representation
318            let specifier_base = python_module_to_relative_specifier(&module_text);
319
320            // Only include relative imports in extract_imports
321            if specifier_base.starts_with("./") || specifier_base.starts_with("../") {
322                // `from . import views` case: specifier_base is "./" (no module part)
323                // In this case the symbol_name IS the module name, so specifier = "./{symbol}"
324                let specifier = if specifier_base == "./"
325                    && !module_text.contains('/')
326                    && module_text.chars().all(|c| c == '.')
327                {
328                    format!("./{symbol_text}")
329                } else {
330                    specifier_base
331                };
332                raw.push((specifier, symbol_text, symbol_line));
333            }
334        }
335
336        // Group by specifier: collect all symbols per specifier
337        let mut specifier_symbols: HashMap<String, Vec<(String, usize)>> = HashMap::new();
338        for (spec, sym, line) in &raw {
339            specifier_symbols
340                .entry(spec.clone())
341                .or_default()
342                .push((sym.clone(), *line));
343        }
344
345        // Build ImportMapping per symbol
346        let mut result = Vec::new();
347        for (specifier, sym_lines) in &specifier_symbols {
348            let all_symbols: Vec<String> = sym_lines.iter().map(|(s, _)| s.clone()).collect();
349            for (sym, line) in sym_lines {
350                result.push(ImportMapping {
351                    symbol_name: sym.clone(),
352                    module_specifier: specifier.clone(),
353                    file: file_path.to_string(),
354                    line: *line,
355                    symbols: all_symbols.clone(),
356                });
357            }
358        }
359
360        result
361    }
362
363    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
364        let mut parser = Self::parser();
365        let tree = match parser.parse(source, None) {
366            Some(t) => t,
367            None => return Vec::new(),
368        };
369        let source_bytes = source.as_bytes();
370        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
371
372        let module_name_idx = query.capture_index_for_name("module_name");
373        let symbol_name_idx = query.capture_index_for_name("symbol_name");
374        let import_name_idx = query.capture_index_for_name("import_name");
375
376        let mut cursor = QueryCursor::new();
377        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
378
379        let mut specifier_symbols: HashMap<String, Vec<String>> = HashMap::new();
380
381        while let Some(m) = matches.next() {
382            let mut module_text: Option<String> = None;
383            let mut symbol_text: Option<String> = None;
384            let mut import_name_parts: Vec<String> = Vec::new();
385
386            for cap in m.captures {
387                if module_name_idx == Some(cap.index) {
388                    module_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
389                } else if symbol_name_idx == Some(cap.index) {
390                    symbol_text = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
391                } else if import_name_idx == Some(cap.index) {
392                    // Use the parent dotted_name node's text to reconstruct the full
393                    // module name (e.g., `os.path` from individual `identifier` captures).
394                    let dotted_text = cap
395                        .node
396                        .parent()
397                        .and_then(|p| p.utf8_text(source_bytes).ok())
398                        .unwrap_or_else(|| cap.node.utf8_text(source_bytes).unwrap_or(""))
399                        .to_string();
400                    import_name_parts.push(dotted_text);
401                }
402            }
403
404            if !import_name_parts.is_empty() {
405                // bare import: `import X` or `import os.path`
406                // Dedup in case multiple identifier captures share the same dotted_name parent.
407                import_name_parts.dedup();
408                let specifier = python_module_to_absolute_specifier(&import_name_parts[0]);
409                if !specifier.starts_with("./")
410                    && !specifier.starts_with("../")
411                    && !specifier.is_empty()
412                {
413                    let attrs =
414                        extract_bare_import_attributes(source_bytes, &tree, &import_name_parts[0]);
415                    specifier_symbols.entry(specifier).or_insert_with(|| attrs);
416                }
417                continue;
418            }
419
420            let (module_text, symbol_text) = match (module_text, symbol_text) {
421                (Some(m), Some(s)) => (m, s),
422                _ => continue,
423            };
424
425            // Convert dotted module path to file path: `myapp.models` -> `myapp/models`
426            let specifier = python_module_to_absolute_specifier(&module_text);
427
428            // Skip relative imports (handled by extract_imports)
429            // Skip empty specifiers (relative-only, like `from . import X` with no module)
430            if specifier.starts_with("./") || specifier.starts_with("../") || specifier.is_empty() {
431                continue;
432            }
433
434            specifier_symbols
435                .entry(specifier)
436                .or_default()
437                .push(symbol_text);
438        }
439
440        specifier_symbols.into_iter().collect()
441    }
442
443    fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
444        let mut parser = Self::parser();
445        let tree = match parser.parse(source, None) {
446            Some(t) => t,
447            None => return Vec::new(),
448        };
449        let source_bytes = source.as_bytes();
450        let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
451
452        let from_specifier_idx = query
453            .capture_index_for_name("from_specifier")
454            .expect("@from_specifier capture not found in re_export.scm");
455        let symbol_name_idx = query.capture_index_for_name("symbol_name");
456        let wildcard_idx = query.capture_index_for_name("wildcard");
457
458        let mut cursor = QueryCursor::new();
459        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
460
461        // Group symbols by from_specifier, tracking wildcard flag separately
462        struct ReExportEntry {
463            symbols: Vec<String>,
464            wildcard: bool,
465        }
466        let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
467
468        while let Some(m) = matches.next() {
469            let mut from_spec: Option<String> = None;
470            let mut sym: Option<String> = None;
471            let mut is_wildcard = false;
472
473            for cap in m.captures {
474                if cap.index == from_specifier_idx {
475                    let raw = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
476                    from_spec = Some(python_module_to_relative_specifier(&raw));
477                } else if wildcard_idx == Some(cap.index) {
478                    is_wildcard = true;
479                } else if symbol_name_idx == Some(cap.index) {
480                    sym = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
481                }
482            }
483
484            if let Some(spec) = from_spec {
485                // Only include relative re-exports
486                if spec.starts_with("./") || spec.starts_with("../") {
487                    let entry = grouped.entry(spec).or_insert(ReExportEntry {
488                        symbols: Vec::new(),
489                        wildcard: false,
490                    });
491                    if is_wildcard {
492                        entry.wildcard = true;
493                    }
494                    if let Some(symbol) = sym {
495                        if !entry.symbols.contains(&symbol) {
496                            entry.symbols.push(symbol);
497                        }
498                    }
499                }
500            }
501        }
502
503        grouped
504            .into_iter()
505            .map(|(from_specifier, entry)| BarrelReExport {
506                symbols: entry.symbols,
507                from_specifier,
508                wildcard: entry.wildcard,
509                namespace_wildcard: false,
510            })
511            .collect()
512    }
513
514    fn source_extensions(&self) -> &[&str] {
515        &["py"]
516    }
517
518    fn index_file_names(&self) -> &[&str] {
519        &["__init__.py"]
520    }
521
522    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
523        production_stem(path)
524    }
525
526    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
527        test_stem(path)
528    }
529
530    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
531        is_non_sut_helper(file_path, is_known_production)
532    }
533
534    fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
535        let source = match std::fs::read_to_string(file_path) {
536            Ok(s) => s,
537            Err(_) => return true, // If we can't read the file, assume it exports everything
538        };
539
540        let mut parser = Self::parser();
541        let tree = match parser.parse(&source, None) {
542            Some(t) => t,
543            None => return true,
544        };
545        let source_bytes = source.as_bytes();
546        let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
547
548        let symbol_idx = query.capture_index_for_name("symbol");
549        let all_decl_idx = query.capture_index_for_name("all_decl");
550        let var_name_idx = query.capture_index_for_name("var_name");
551
552        let mut cursor = QueryCursor::new();
553        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
554
555        let mut all_symbols: Vec<String> = Vec::new();
556        let mut found_all = false;
557
558        while let Some(m) = matches.next() {
559            for cap in m.captures {
560                // Detect __all__ existence via @var_name (pattern 1) or @all_decl (pattern 2)
561                if var_name_idx == Some(cap.index) || all_decl_idx == Some(cap.index) {
562                    found_all = true;
563                } else if symbol_idx == Some(cap.index) {
564                    let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
565                    let stripped = raw.trim_matches(|c| c == '\'' || c == '"');
566                    all_symbols.push(stripped.to_string());
567                }
568            }
569        }
570
571        if !found_all {
572            // No __all__ defined: treat as exporting everything
573            return true;
574        }
575
576        // __all__ defined (possibly empty): check if any requested symbol is exported
577        symbols.iter().any(|s| all_symbols.contains(s))
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Module path conversion helpers
583// ---------------------------------------------------------------------------
584
585/// Convert a Python module specifier (as tree-sitter captures it) to a relative path specifier.
586///
587/// Tree-sitter-python represents `from .models import X` with module_name capturing `.models`
588/// and `from ..utils import Y` with `..utils`.
589/// `from . import views` has module_name capturing `.` (just dots).
590///
591/// We convert:
592/// - `.models`  -> `./models`
593/// - `..utils`  -> `../utils`
594/// - `.`        -> `.` (handled separately as `from . import X`)
595/// - `..`       -> `..` (handled separately)
596fn python_module_to_relative_specifier(module: &str) -> String {
597    // Count leading dots
598    let dot_count = module.chars().take_while(|&c| c == '.').count();
599    if dot_count == 0 {
600        // Not a relative import
601        return module.to_string();
602    }
603
604    let rest = &module[dot_count..];
605
606    if dot_count == 1 {
607        if rest.is_empty() {
608            // `from . import X` -> specifier will be derived from symbol name
609            // Return "./" as placeholder; caller uses the symbol as the path segment
610            "./".to_string()
611        } else {
612            format!("./{rest}")
613        }
614    } else {
615        // dot_count >= 2: `..` = `../`, `...` = `../../`, etc.
616        let prefix = "../".repeat(dot_count - 1);
617        if rest.is_empty() {
618            // `from .. import X`
619            prefix
620        } else {
621            format!("{prefix}{rest}")
622        }
623    }
624}
625
626/// Convert a Python absolute module path to a file-system path specifier.
627/// `myapp.models` -> `myapp/models`
628/// `.models`      -> (relative, skip)
629fn python_module_to_absolute_specifier(module: &str) -> String {
630    if module.starts_with('.') {
631        // Relative import - not handled here
632        return python_module_to_relative_specifier(module);
633    }
634    module.replace('.', "/")
635}
636
637// ---------------------------------------------------------------------------
638// Concrete methods (not in trait)
639// ---------------------------------------------------------------------------
640
641/// Extract the set of import symbol names that appear (directly or via
642/// variable chain) inside assertion nodes.
643///
644/// Algorithm:
645/// 1. Parse source and find all assertion byte ranges via `assertion.scm`.
646/// 2. Walk the AST within each assertion range to collect all `identifier`
647///    leaf nodes → `assertion_identifiers`.
648/// 3. Parse assignment mappings via `assignment_mapping.scm`:
649///    - `@var` → `@class`  (direct: `var = ClassName()`)
650///    - `@var` → `@source` (chain: `var = obj.method()`)
651/// 4. Chain-expand `assertion_identifiers` up to 2 hops, resolving var →
652///    class via the assignment map.
653/// 5. Return the union of all resolved symbols.
654///
655/// Returns an empty `HashSet` when no assertions are found (caller is
656/// responsible for the safe fallback to `all_matched`).
657pub fn extract_assertion_referenced_imports(source: &str) -> HashSet<String> {
658    let mut parser = PythonExtractor::parser();
659    let tree = match parser.parse(source, None) {
660        Some(t) => t,
661        None => return HashSet::new(),
662    };
663    let source_bytes = source.as_bytes();
664
665    // ---- Step 1: collect assertion byte ranges ----
666    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
667    let assertion_cap_idx = match assertion_query.capture_index_for_name("assertion") {
668        Some(idx) => idx,
669        None => return HashSet::new(),
670    };
671
672    let mut assertion_ranges: Vec<(usize, usize)> = Vec::new();
673    {
674        let mut cursor = QueryCursor::new();
675        let mut matches = cursor.matches(assertion_query, tree.root_node(), source_bytes);
676        while let Some(m) = matches.next() {
677            for cap in m.captures {
678                if cap.index == assertion_cap_idx {
679                    let r = cap.node.byte_range();
680                    assertion_ranges.push((r.start, r.end));
681                }
682            }
683        }
684    }
685
686    if assertion_ranges.is_empty() {
687        return HashSet::new();
688    }
689
690    // ---- Step 2: collect identifiers within assertion ranges (AST walk) ----
691    let mut assertion_identifiers: HashSet<String> = HashSet::new();
692    {
693        let root = tree.root_node();
694        let mut stack = vec![root];
695        while let Some(node) = stack.pop() {
696            let nr = node.byte_range();
697            // Only descend into nodes that overlap with at least one assertion range
698            let overlaps = assertion_ranges
699                .iter()
700                .any(|&(s, e)| nr.start < e && nr.end > s);
701            if !overlaps {
702                continue;
703            }
704            if node.kind() == "identifier" {
705                // The identifier itself must be within an assertion range
706                if assertion_ranges
707                    .iter()
708                    .any(|&(s, e)| nr.start >= s && nr.end <= e)
709                {
710                    if let Ok(text) = node.utf8_text(source_bytes) {
711                        if !text.is_empty() {
712                            assertion_identifiers.insert(text.to_string());
713                        }
714                    }
715                }
716            }
717            for i in 0..node.child_count() {
718                if let Some(child) = node.child(i) {
719                    stack.push(child);
720                }
721            }
722        }
723    }
724
725    // ---- Step 3: build assignment map ----
726    // Maps var_name → set of resolved names (class or source object)
727    let assign_query = cached_query(&ASSIGNMENT_MAPPING_QUERY_CACHE, ASSIGNMENT_MAPPING_QUERY);
728    let var_idx = assign_query.capture_index_for_name("var");
729    let class_idx = assign_query.capture_index_for_name("class");
730    let source_idx = assign_query.capture_index_for_name("source");
731
732    // var → Vec<target_symbol>
733    let mut assignment_map: HashMap<String, Vec<String>> = HashMap::new();
734
735    if let (Some(var_cap), Some(class_cap), Some(source_cap)) = (var_idx, class_idx, source_idx) {
736        let mut cursor = QueryCursor::new();
737        let mut matches = cursor.matches(assign_query, tree.root_node(), source_bytes);
738        while let Some(m) = matches.next() {
739            let mut var_text = String::new();
740            let mut target_text = String::new();
741            for cap in m.captures {
742                if cap.index == var_cap {
743                    var_text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
744                } else if cap.index == class_cap || cap.index == source_cap {
745                    let t = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
746                    if !t.is_empty() {
747                        target_text = t;
748                    }
749                }
750            }
751            if !var_text.is_empty() && !target_text.is_empty() && var_text != target_text {
752                assignment_map
753                    .entry(var_text)
754                    .or_default()
755                    .push(target_text);
756            }
757        }
758    }
759
760    // ---- Step 4: chain-expand up to 2 hops ----
761    let mut resolved: HashSet<String> = assertion_identifiers.clone();
762    for _ in 0..2 {
763        let mut additions: HashSet<String> = HashSet::new();
764        for sym in &resolved {
765            if let Some(targets) = assignment_map.get(sym) {
766                for t in targets {
767                    additions.insert(t.clone());
768                }
769            }
770        }
771        let before = resolved.len();
772        resolved.extend(additions);
773        if resolved.len() == before {
774            break;
775        }
776    }
777
778    resolved
779}
780
781/// Track newly matched production-file indices and the symbols that caused
782/// them.  Called after each `collect_import_matches` invocation to update
783/// `idx_to_symbols` with the diff between `all_matched` before and after.
784fn track_new_matches(
785    all_matched: &HashSet<usize>,
786    before: &HashSet<usize>,
787    symbols: &[String],
788    idx_to_symbols: &mut HashMap<usize, HashSet<String>>,
789) {
790    for &new_idx in all_matched.difference(before) {
791        let entry = idx_to_symbols.entry(new_idx).or_default();
792        for s in symbols {
793            entry.insert(s.clone());
794        }
795    }
796}
797
798impl PythonExtractor {
799    /// Layer 1 + Layer 2: Map test files to production files.
800    pub fn map_test_files_with_imports(
801        &self,
802        production_files: &[String],
803        test_sources: &HashMap<String, String>,
804        scan_root: &Path,
805    ) -> Vec<FileMapping> {
806        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
807
808        // Phase 20: Filter out test-directory helper files from production_files before
809        // passing to Layer 1. Files inside tests/ or test/ path segments (relative to
810        // scan_root) are helpers (e.g. tests/helpers.py, tests/testserver/server.py)
811        // even when discover_files classifies them as production files.
812        // We strip the scan_root prefix to get the relative path for segment checking,
813        // avoiding false positives when the absolute path itself contains "tests" segments
814        // (e.g. /path/to/project/tests/fixtures/observe/e2e_pkg/views.py).
815        let canonical_root_for_filter = scan_root.canonicalize().ok();
816        let filtered_production_files: Vec<String> = production_files
817            .iter()
818            .filter(|p| {
819                let check_path = if let Some(ref root) = canonical_root_for_filter {
820                    if let Ok(canonical_p) = Path::new(p).canonicalize() {
821                        if let Ok(rel) = canonical_p.strip_prefix(root) {
822                            rel.to_string_lossy().into_owned()
823                        } else {
824                            p.to_string()
825                        }
826                    } else {
827                        p.to_string()
828                    }
829                } else {
830                    p.to_string()
831                };
832                !is_non_sut_helper(&check_path, false)
833            })
834            .cloned()
835            .collect();
836
837        // Layer 1: filename convention
838        let mut mappings =
839            exspec_core::observe::map_test_files(self, &filtered_production_files, &test_file_list);
840
841        // Build canonical path -> production index lookup
842        let canonical_root = match scan_root.canonicalize() {
843            Ok(r) => r,
844            Err(_) => return mappings,
845        };
846        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
847        for (idx, prod) in filtered_production_files.iter().enumerate() {
848            if let Ok(canonical) = Path::new(prod).canonicalize() {
849                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
850            }
851        }
852
853        // Record Layer 1 core matches per production file index
854        let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
855            .iter()
856            .map(|m| m.test_files.iter().cloned().collect())
857            .collect();
858
859        // Layer 1 extension: stem-only fallback (cross-directory match)
860        // For test files that L1 core did not match, attempt stem-only match against all prod files.
861        // This handles the httpx-like case where tests/ and pkg/ are in different directories.
862        {
863            // Build stem -> list of production indices (stem stripped of leading `_`)
864            let mut stem_to_prod_indices: HashMap<String, Vec<usize>> = HashMap::new();
865            for (idx, prod) in filtered_production_files.iter().enumerate() {
866                if let Some(pstem) = self.production_stem(prod) {
867                    stem_to_prod_indices
868                        .entry(pstem.to_owned())
869                        .or_default()
870                        .push(idx);
871                }
872            }
873
874            // Collect set of test files already matched by L1 core (any prod)
875            let l1_core_matched: HashSet<&str> = layer1_tests_per_prod
876                .iter()
877                .flat_map(|s| s.iter().map(|t| t.as_str()))
878                .collect();
879
880            for test_file in &test_file_list {
881                // Skip if L1 core already matched this test file
882                if l1_core_matched.contains(test_file.as_str()) {
883                    continue;
884                }
885                if let Some(tstem) = self.test_stem(test_file) {
886                    if let Some(prod_indices) = stem_to_prod_indices.get(tstem) {
887                        for &idx in prod_indices {
888                            if !mappings[idx].test_files.contains(test_file) {
889                                mappings[idx].test_files.push(test_file.clone());
890                            }
891                        }
892                    }
893                }
894            }
895        }
896
897        // Snapshot L1 (core + stem-only fallback) matches per prod for strategy update
898        let layer1_extended_tests_per_prod: Vec<HashSet<String>> = mappings
899            .iter()
900            .map(|m| m.test_files.iter().cloned().collect())
901            .collect();
902
903        // Collect set of test files matched by L1 (core + stem-only fallback) for barrel suppression
904        let l1_matched_tests: HashSet<String> = mappings
905            .iter()
906            .flat_map(|m| m.test_files.iter().cloned())
907            .collect();
908
909        // Layer 2: import tracing
910        for (test_file, source) in test_sources {
911            let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
912            let from_file = Path::new(test_file);
913            // all_matched: every idx matched by L2 (traditional behavior)
914            let mut all_matched = HashSet::<usize>::new();
915            // idx_to_symbols: tracks which import symbols caused each idx match
916            let mut idx_to_symbols: HashMap<usize, HashSet<String>> = HashMap::new();
917
918            for import in &imports {
919                // Handle bare relative imports: `from . import X` (specifier="./")
920                // or `from .. import X` (specifier="../"), etc.
921                // These need per-symbol resolution since the module part is the symbol name.
922                let is_bare_relative = (import.module_specifier == "./"
923                    || import.module_specifier.ends_with('/'))
924                    && import
925                        .module_specifier
926                        .trim_end_matches('/')
927                        .chars()
928                        .all(|c| c == '.');
929
930                let specifier = if is_bare_relative {
931                    let prefix =
932                        &import.module_specifier[..import.module_specifier.len().saturating_sub(1)];
933                    for sym in &import.symbols {
934                        let sym_specifier = format!("{prefix}/{sym}");
935                        if let Some(resolved) = exspec_core::observe::resolve_import_path(
936                            self,
937                            &sym_specifier,
938                            from_file,
939                            &canonical_root,
940                        ) {
941                            // Barrel suppression: skip barrel-resolved imports for L1-matched tests
942                            if self.is_barrel_file(&resolved)
943                                && l1_matched_tests.contains(test_file.as_str())
944                            {
945                                continue;
946                            }
947                            let sym_slice = &[sym.clone()];
948                            let before = all_matched.clone();
949                            exspec_core::observe::collect_import_matches(
950                                self,
951                                &resolved,
952                                sym_slice,
953                                &canonical_to_idx,
954                                &mut all_matched,
955                                &canonical_root,
956                            );
957                            track_new_matches(
958                                &all_matched,
959                                &before,
960                                sym_slice,
961                                &mut idx_to_symbols,
962                            );
963                        }
964                    }
965                    continue;
966                } else {
967                    import.module_specifier.clone()
968                };
969
970                if let Some(resolved) = exspec_core::observe::resolve_import_path(
971                    self,
972                    &specifier,
973                    from_file,
974                    &canonical_root,
975                ) {
976                    // Barrel suppression: skip barrel-resolved imports for L1-matched tests
977                    if self.is_barrel_file(&resolved)
978                        && l1_matched_tests.contains(test_file.as_str())
979                    {
980                        continue;
981                    }
982                    let before = all_matched.clone();
983                    exspec_core::observe::collect_import_matches(
984                        self,
985                        &resolved,
986                        &import.symbols,
987                        &canonical_to_idx,
988                        &mut all_matched,
989                        &canonical_root,
990                    );
991                    track_new_matches(&all_matched, &before, &import.symbols, &mut idx_to_symbols);
992                }
993            }
994
995            // Layer 2 (absolute imports): resolve from scan_root
996            let abs_specifiers = self.extract_all_import_specifiers(source);
997            for (specifier, symbols) in &abs_specifiers {
998                let base = canonical_root.join(specifier);
999                let resolved = exspec_core::observe::resolve_absolute_base_to_file(
1000                    self,
1001                    &base,
1002                    &canonical_root,
1003                )
1004                .or_else(|| {
1005                    let src_base = canonical_root.join("src").join(specifier);
1006                    exspec_core::observe::resolve_absolute_base_to_file(
1007                        self,
1008                        &src_base,
1009                        &canonical_root,
1010                    )
1011                });
1012                if let Some(resolved) = resolved {
1013                    // Barrel suppression: skip barrel-resolved imports for L1-matched tests
1014                    if self.is_barrel_file(&resolved)
1015                        && l1_matched_tests.contains(test_file.as_str())
1016                    {
1017                        continue;
1018                    }
1019                    let before = all_matched.clone();
1020                    exspec_core::observe::collect_import_matches(
1021                        self,
1022                        &resolved,
1023                        symbols,
1024                        &canonical_to_idx,
1025                        &mut all_matched,
1026                        &canonical_root,
1027                    );
1028                    track_new_matches(&all_matched, &before, symbols, &mut idx_to_symbols);
1029                }
1030            }
1031
1032            // Assertion-referenced import filter (safe fallback)
1033            let asserted_imports = extract_assertion_referenced_imports(source);
1034            let final_indices: HashSet<usize> = if asserted_imports.is_empty() {
1035                // No assertions found -> fallback: use all_matched (PY-AF-06a)
1036                all_matched.clone()
1037            } else {
1038                // Filter to indices whose symbols intersect with asserted_imports
1039                let asserted_matched: HashSet<usize> = all_matched
1040                    .iter()
1041                    .copied()
1042                    .filter(|idx| {
1043                        idx_to_symbols
1044                            .get(idx)
1045                            .map(|syms| syms.iter().any(|s| asserted_imports.contains(s)))
1046                            .unwrap_or(false)
1047                    })
1048                    .collect();
1049                if asserted_matched.is_empty() {
1050                    // Assertions exist but no import symbol intersects -> safe fallback (PY-AF-06b, PY-AF-09)
1051                    all_matched.clone()
1052                } else {
1053                    asserted_matched
1054                }
1055            };
1056
1057            for idx in final_indices {
1058                if !mappings[idx].test_files.contains(test_file) {
1059                    mappings[idx].test_files.push(test_file.clone());
1060                }
1061            }
1062        }
1063
1064        // Update strategy: if a production file had no Layer 1 matches (core + stem-only fallback)
1065        // but has Layer 2 matches, set strategy to ImportTracing
1066        for (i, mapping) in mappings.iter_mut().enumerate() {
1067            let has_layer1 = !layer1_extended_tests_per_prod[i].is_empty();
1068            if !has_layer1 && !mapping.test_files.is_empty() {
1069                mapping.strategy = MappingStrategy::ImportTracing;
1070            }
1071        }
1072
1073        mappings
1074    }
1075}
1076
1077// ---------------------------------------------------------------------------
1078// Tests
1079// ---------------------------------------------------------------------------
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use std::path::PathBuf;
1085
1086    // -----------------------------------------------------------------------
1087    // PY-STEM-01: test_user.py -> test_stem = Some("user")
1088    // -----------------------------------------------------------------------
1089    #[test]
1090    fn py_stem_01_test_prefix() {
1091        // Given: a file named test_user.py
1092        // When: test_stem is called
1093        // Then: returns Some("user")
1094        let extractor = PythonExtractor::new();
1095        let result = extractor.test_stem("tests/test_user.py");
1096        assert_eq!(result, Some("user"));
1097    }
1098
1099    // -----------------------------------------------------------------------
1100    // PY-STEM-02: user_test.py -> test_stem = Some("user")
1101    // -----------------------------------------------------------------------
1102    #[test]
1103    fn py_stem_02_test_suffix() {
1104        // Given: a file named user_test.py
1105        // When: test_stem is called
1106        // Then: returns Some("user")
1107        let extractor = PythonExtractor::new();
1108        let result = extractor.test_stem("tests/user_test.py");
1109        assert_eq!(result, Some("user"));
1110    }
1111
1112    // -----------------------------------------------------------------------
1113    // PY-STEM-03: test_user_service.py -> test_stem = Some("user_service")
1114    // -----------------------------------------------------------------------
1115    #[test]
1116    fn py_stem_03_test_prefix_multi_segment() {
1117        // Given: a file named test_user_service.py
1118        // When: test_stem is called
1119        // Then: returns Some("user_service")
1120        let extractor = PythonExtractor::new();
1121        let result = extractor.test_stem("tests/test_user_service.py");
1122        assert_eq!(result, Some("user_service"));
1123    }
1124
1125    // -----------------------------------------------------------------------
1126    // PY-STEM-04: user.py -> production_stem = Some("user")
1127    // -----------------------------------------------------------------------
1128    #[test]
1129    fn py_stem_04_production_stem_regular() {
1130        // Given: a regular production file user.py
1131        // When: production_stem is called
1132        // Then: returns Some("user")
1133        let extractor = PythonExtractor::new();
1134        let result = extractor.production_stem("src/user.py");
1135        assert_eq!(result, Some("user"));
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // PY-STEM-05: __init__.py -> production_stem = None
1140    // -----------------------------------------------------------------------
1141    #[test]
1142    fn py_stem_05_production_stem_init() {
1143        // Given: __init__.py (barrel file)
1144        // When: production_stem is called
1145        // Then: returns None
1146        let extractor = PythonExtractor::new();
1147        let result = extractor.production_stem("src/__init__.py");
1148        assert_eq!(result, None);
1149    }
1150
1151    // -----------------------------------------------------------------------
1152    // PY-STEM-06: test_user.py -> production_stem = None
1153    // -----------------------------------------------------------------------
1154    #[test]
1155    fn py_stem_06_production_stem_test_file() {
1156        // Given: a test file test_user.py
1157        // When: production_stem is called
1158        // Then: returns None (test files are not production)
1159        let extractor = PythonExtractor::new();
1160        let result = extractor.production_stem("tests/test_user.py");
1161        assert_eq!(result, None);
1162    }
1163
1164    // -----------------------------------------------------------------------
1165    // PY-HELPER-01: conftest.py -> is_non_sut_helper = true
1166    // -----------------------------------------------------------------------
1167    #[test]
1168    fn py_helper_01_conftest() {
1169        // Given: conftest.py
1170        // When: is_non_sut_helper is called
1171        // Then: returns true
1172        let extractor = PythonExtractor::new();
1173        assert!(extractor.is_non_sut_helper("tests/conftest.py", false));
1174    }
1175
1176    // -----------------------------------------------------------------------
1177    // PY-HELPER-02: constants.py -> is_non_sut_helper = true
1178    // -----------------------------------------------------------------------
1179    #[test]
1180    fn py_helper_02_constants() {
1181        // Given: constants.py
1182        // When: is_non_sut_helper is called
1183        // Then: returns true
1184        let extractor = PythonExtractor::new();
1185        assert!(extractor.is_non_sut_helper("src/constants.py", false));
1186    }
1187
1188    // -----------------------------------------------------------------------
1189    // PY-HELPER-03: __init__.py -> is_non_sut_helper = true
1190    // -----------------------------------------------------------------------
1191    #[test]
1192    fn py_helper_03_init() {
1193        // Given: __init__.py
1194        // When: is_non_sut_helper is called
1195        // Then: returns true
1196        let extractor = PythonExtractor::new();
1197        assert!(extractor.is_non_sut_helper("src/__init__.py", false));
1198    }
1199
1200    // -----------------------------------------------------------------------
1201    // PY-HELPER-04: tests/utils.py -> is_non_sut_helper = true
1202    // -----------------------------------------------------------------------
1203    #[test]
1204    fn py_helper_04_utils_under_tests_dir() {
1205        // Given: utils.py under tests/ directory (not a test file)
1206        // When: is_non_sut_helper is called
1207        // Then: returns true
1208        let extractor = PythonExtractor::new();
1209        assert!(extractor.is_non_sut_helper("tests/utils.py", false));
1210    }
1211
1212    // -----------------------------------------------------------------------
1213    // PY-HELPER-05: models.py -> is_non_sut_helper = false
1214    // -----------------------------------------------------------------------
1215    #[test]
1216    fn py_helper_05_models_is_not_helper() {
1217        // Given: models.py (regular production file)
1218        // When: is_non_sut_helper is called
1219        // Then: returns false
1220        let extractor = PythonExtractor::new();
1221        assert!(!extractor.is_non_sut_helper("src/models.py", false));
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // PY-HELPER-06: tests/common.py -> helper even when is_known_production=true
1226    // -----------------------------------------------------------------------
1227    #[test]
1228    fn py_helper_06_tests_common_helper_despite_known_production() {
1229        // Given: file is tests/common.py with is_known_production=true
1230        // When: is_non_sut_helper is called
1231        // Then: returns true (path segment check overrides is_known_production)
1232        let extractor = PythonExtractor::new();
1233        assert!(extractor.is_non_sut_helper("tests/common.py", true));
1234    }
1235
1236    // -----------------------------------------------------------------------
1237    // PY-HELPER-07: tests/testserver/server.py -> helper (subdirectory of tests/)
1238    // -----------------------------------------------------------------------
1239    #[test]
1240    fn py_helper_07_tests_subdirectory_helper() {
1241        // Given: file is tests/testserver/server.py (inside tests/ dir but not a test file)
1242        // When: is_non_sut_helper is called
1243        // Then: returns true (path segment check catches subdirectories)
1244        let extractor = PythonExtractor::new();
1245        assert!(extractor.is_non_sut_helper("tests/testserver/server.py", true));
1246    }
1247
1248    // -----------------------------------------------------------------------
1249    // PY-HELPER-08: tests/compat.py -> helper (is_known_production=false)
1250    // -----------------------------------------------------------------------
1251    #[test]
1252    fn py_helper_08_tests_compat_helper() {
1253        // Given: file is tests/compat.py (inside tests/ dir, not a test file)
1254        // When: is_non_sut_helper is called
1255        // Then: returns true
1256        let extractor = PythonExtractor::new();
1257        assert!(extractor.is_non_sut_helper("tests/compat.py", false));
1258    }
1259
1260    // -----------------------------------------------------------------------
1261    // PY-HELPER-09: tests/fixtures/data.py -> helper (deep nesting inside tests/)
1262    // -----------------------------------------------------------------------
1263    #[test]
1264    fn py_helper_09_deep_nested_test_dir_helper() {
1265        // Given: file is tests/fixtures/data.py (deeply nested inside tests/)
1266        // When: is_non_sut_helper is called
1267        // Then: returns true (path segment check catches any depth under tests/)
1268        let extractor = PythonExtractor::new();
1269        assert!(extractor.is_non_sut_helper("tests/fixtures/data.py", false));
1270    }
1271
1272    // -----------------------------------------------------------------------
1273    // PY-HELPER-10: src/tests.py -> NOT helper (filename not dir segment)
1274    // -----------------------------------------------------------------------
1275    #[test]
1276    fn py_helper_10_tests_in_filename_not_helper() {
1277        // Given: file is src/tests.py ("tests" is in filename, not a directory segment)
1278        // When: is_non_sut_helper is called
1279        // Then: returns false (path segment check must not match filename)
1280        let extractor = PythonExtractor::new();
1281        assert!(!extractor.is_non_sut_helper("src/tests.py", false));
1282    }
1283
1284    // -----------------------------------------------------------------------
1285    // PY-HELPER-11: test/helpers.py -> helper (test/ singular directory)
1286    // -----------------------------------------------------------------------
1287    #[test]
1288    fn py_helper_11_test_singular_dir_helper() {
1289        // Given: file is test/helpers.py (singular "test" directory, not "tests")
1290        // When: is_non_sut_helper is called
1291        // Then: returns true (segment check matches both "tests" and "test")
1292        let extractor = PythonExtractor::new();
1293        assert!(extractor.is_non_sut_helper("test/helpers.py", true));
1294    }
1295
1296    // -----------------------------------------------------------------------
1297    // PY-BARREL-01: __init__.py -> is_barrel_file = true
1298    // -----------------------------------------------------------------------
1299    #[test]
1300    fn py_barrel_01_init_is_barrel() {
1301        // Given: __init__.py
1302        // When: is_barrel_file is called
1303        // Then: returns true
1304        let extractor = PythonExtractor::new();
1305        assert!(extractor.is_barrel_file("src/mypackage/__init__.py"));
1306    }
1307
1308    // -----------------------------------------------------------------------
1309    // PY-FUNC-01: def create_user() -> name="create_user", class_name=None
1310    // -----------------------------------------------------------------------
1311    #[test]
1312    fn py_func_01_top_level_function() {
1313        // Given: Python source with a top-level function
1314        let source = r#"
1315def create_user():
1316    pass
1317"#;
1318        // When: extract_production_functions is called
1319        let extractor = PythonExtractor::new();
1320        let result = extractor.extract_production_functions(source, "src/users.py");
1321
1322        // Then: name="create_user", class_name=None
1323        let func = result.iter().find(|f| f.name == "create_user");
1324        assert!(func.is_some(), "create_user not found in {:?}", result);
1325        let func = func.unwrap();
1326        assert_eq!(func.class_name, None);
1327    }
1328
1329    // -----------------------------------------------------------------------
1330    // PY-FUNC-02: class User: def save(self) -> name="save", class_name=Some("User")
1331    // -----------------------------------------------------------------------
1332    #[test]
1333    fn py_func_02_class_method() {
1334        // Given: Python source with a class containing a method
1335        let source = r#"
1336class User:
1337    def save(self):
1338        pass
1339"#;
1340        // When: extract_production_functions is called
1341        let extractor = PythonExtractor::new();
1342        let result = extractor.extract_production_functions(source, "src/models.py");
1343
1344        // Then: name="save", class_name=Some("User")
1345        let method = result.iter().find(|f| f.name == "save");
1346        assert!(method.is_some(), "save not found in {:?}", result);
1347        let method = method.unwrap();
1348        assert_eq!(method.class_name, Some("User".to_string()));
1349    }
1350
1351    // -----------------------------------------------------------------------
1352    // PY-FUNC-03: @decorator def endpoint() -> extracted
1353    // -----------------------------------------------------------------------
1354    #[test]
1355    fn py_func_03_decorated_function() {
1356        // Given: Python source with a decorated function
1357        let source = r#"
1358import functools
1359
1360def my_decorator(func):
1361    @functools.wraps(func)
1362    def wrapper(*args, **kwargs):
1363        return func(*args, **kwargs)
1364    return wrapper
1365
1366@my_decorator
1367def endpoint():
1368    pass
1369"#;
1370        // When: extract_production_functions is called
1371        let extractor = PythonExtractor::new();
1372        let result = extractor.extract_production_functions(source, "src/views.py");
1373
1374        // Then: endpoint is extracted
1375        let func = result.iter().find(|f| f.name == "endpoint");
1376        assert!(func.is_some(), "endpoint not found in {:?}", result);
1377    }
1378
1379    // -----------------------------------------------------------------------
1380    // PY-IMP-01: from .models import User -> specifier="./models", symbols=["User"]
1381    // -----------------------------------------------------------------------
1382    #[test]
1383    fn py_imp_01_relative_import_from_dot() {
1384        // Given: source with relative import from .models
1385        let source = "from .models import User\n";
1386
1387        // When: extract_imports is called
1388        let extractor = PythonExtractor::new();
1389        let result = extractor.extract_imports(source, "tests/test_user.py");
1390
1391        // Then: one entry with specifier="./models", symbols=["User"]
1392        let imp = result.iter().find(|i| i.module_specifier == "./models");
1393        assert!(
1394            imp.is_some(),
1395            "import from ./models not found in {:?}",
1396            result
1397        );
1398        let imp = imp.unwrap();
1399        assert!(
1400            imp.symbols.contains(&"User".to_string()),
1401            "User not in symbols: {:?}",
1402            imp.symbols
1403        );
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // PY-IMP-02: from ..utils import helper -> specifier="../utils", symbols=["helper"]
1408    // -----------------------------------------------------------------------
1409    #[test]
1410    fn py_imp_02_relative_import_two_dots() {
1411        // Given: source with two-dot relative import
1412        let source = "from ..utils import helper\n";
1413
1414        // When: extract_imports is called
1415        let extractor = PythonExtractor::new();
1416        let result = extractor.extract_imports(source, "tests/unit/test_something.py");
1417
1418        // Then: one entry with specifier="../utils", symbols=["helper"]
1419        let imp = result.iter().find(|i| i.module_specifier == "../utils");
1420        assert!(
1421            imp.is_some(),
1422            "import from ../utils not found in {:?}",
1423            result
1424        );
1425        let imp = imp.unwrap();
1426        assert!(
1427            imp.symbols.contains(&"helper".to_string()),
1428            "helper not in symbols: {:?}",
1429            imp.symbols
1430        );
1431    }
1432
1433    // -----------------------------------------------------------------------
1434    // PY-IMP-03: from myapp.models import User -> ("myapp/models", ["User"])
1435    // -----------------------------------------------------------------------
1436    #[test]
1437    fn py_imp_03_absolute_import_dotted() {
1438        // Given: source with absolute import using dotted module path
1439        let source = "from myapp.models import User\n";
1440
1441        // When: extract_all_import_specifiers is called
1442        let extractor = PythonExtractor::new();
1443        let result = extractor.extract_all_import_specifiers(source);
1444
1445        // Then: contains ("myapp/models", ["User"])
1446        let entry = result.iter().find(|(spec, _)| spec == "myapp/models");
1447        assert!(entry.is_some(), "myapp/models not found in {:?}", result);
1448        let (_, symbols) = entry.unwrap();
1449        assert!(
1450            symbols.contains(&"User".to_string()),
1451            "User not in symbols: {:?}",
1452            symbols
1453        );
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // PY-IMP-04: import os -> not resolved (skipped)
1458    // -----------------------------------------------------------------------
1459    #[test]
1460    fn py_imp_04_plain_import_skipped() {
1461        // Given: source with a plain stdlib import
1462        let source = "import os\n";
1463
1464        // When: extract_all_import_specifiers is called
1465        let extractor = PythonExtractor::new();
1466        let result = extractor.extract_all_import_specifiers(source);
1467
1468        // Then: "os" is present with empty symbols (bare import produces no symbol constraints)
1469        let os_entry = result.iter().find(|(spec, _)| spec == "os");
1470        assert!(
1471            os_entry.is_some(),
1472            "plain 'import os' should be included as bare import, got {:?}",
1473            result
1474        );
1475        let (_, symbols) = os_entry.unwrap();
1476        assert!(
1477            symbols.is_empty(),
1478            "expected empty symbols for bare import, got {:?}",
1479            symbols
1480        );
1481    }
1482
1483    // -----------------------------------------------------------------------
1484    // PY-IMP-05: from . import views -> specifier="./views", symbols=["views"]
1485    // -----------------------------------------------------------------------
1486    #[test]
1487    fn py_imp_05_from_dot_import_name() {
1488        // Given: source with `from . import views`
1489        let source = "from . import views\n";
1490
1491        // When: extract_imports is called
1492        let extractor = PythonExtractor::new();
1493        let result = extractor.extract_imports(source, "tests/test_app.py");
1494
1495        // Then: specifier="./views", symbols=["views"]
1496        let imp = result.iter().find(|i| i.module_specifier == "./views");
1497        assert!(imp.is_some(), "./views not found in {:?}", result);
1498        let imp = imp.unwrap();
1499        assert!(
1500            imp.symbols.contains(&"views".to_string()),
1501            "views not in symbols: {:?}",
1502            imp.symbols
1503        );
1504    }
1505
1506    // -----------------------------------------------------------------------
1507    // PY-IMPORT-01: `import httpx` -> specifier="httpx", symbols=[]
1508    // -----------------------------------------------------------------------
1509    #[test]
1510    fn py_import_01_bare_import_simple() {
1511        // Given: source with a bare import of a third-party package
1512        let source = "import httpx\n";
1513
1514        // When: extract_all_import_specifiers is called
1515        let extractor = PythonExtractor::new();
1516        let result = extractor.extract_all_import_specifiers(source);
1517
1518        // Then: contains ("httpx", []) -- bare import produces empty symbols
1519        let entry = result.iter().find(|(spec, _)| spec == "httpx");
1520        assert!(
1521            entry.is_some(),
1522            "httpx not found in {:?}; bare import should be included",
1523            result
1524        );
1525        let (_, symbols) = entry.unwrap();
1526        assert!(
1527            symbols.is_empty(),
1528            "expected empty symbols for bare import, got {:?}",
1529            symbols
1530        );
1531    }
1532
1533    // -----------------------------------------------------------------------
1534    // PY-IMPORT-02: `import os.path` -> specifier="os/path", symbols=[]
1535    // -----------------------------------------------------------------------
1536    #[test]
1537    fn py_import_02_bare_import_dotted() {
1538        // Given: source with a dotted bare import
1539        let source = "import os.path\n";
1540
1541        // When: extract_all_import_specifiers is called
1542        let extractor = PythonExtractor::new();
1543        let result = extractor.extract_all_import_specifiers(source);
1544
1545        // Then: contains ("os/path", []) -- dots converted to slashes
1546        let entry = result.iter().find(|(spec, _)| spec == "os/path");
1547        assert!(
1548            entry.is_some(),
1549            "os/path not found in {:?}; dotted bare import should be converted",
1550            result
1551        );
1552        let (_, symbols) = entry.unwrap();
1553        assert!(
1554            symbols.is_empty(),
1555            "expected empty symbols for dotted bare import, got {:?}",
1556            symbols
1557        );
1558    }
1559
1560    // -----------------------------------------------------------------------
1561    // PY-IMPORT-03: `from httpx import Client` -> specifier="httpx", symbols=["Client"]
1562    //               (regression: from-import still works after bare-import change)
1563    // -----------------------------------------------------------------------
1564    #[test]
1565    fn py_import_03_from_import_regression() {
1566        // Given: source with a from-import (existing behaviour must not regress)
1567        let source = "from httpx import Client\n";
1568
1569        // When: extract_all_import_specifiers is called
1570        let extractor = PythonExtractor::new();
1571        let result = extractor.extract_all_import_specifiers(source);
1572
1573        // Then: contains ("httpx", ["Client"])
1574        let entry = result.iter().find(|(spec, _)| spec == "httpx");
1575        assert!(entry.is_some(), "httpx not found in {:?}", result);
1576        let (_, symbols) = entry.unwrap();
1577        assert!(
1578            symbols.contains(&"Client".to_string()),
1579            "Client not in symbols: {:?}",
1580            symbols
1581        );
1582    }
1583
1584    // -----------------------------------------------------------------------
1585    // PY-BARREL-02: __init__.py with `from .module import Foo`
1586    //               -> extract_barrel_re_exports: symbols=["Foo"], from_specifier="./module"
1587    // -----------------------------------------------------------------------
1588    #[test]
1589    fn py_barrel_02_re_export_named() {
1590        // Given: __init__.py content with a named re-export
1591        let source = "from .module import Foo\n";
1592
1593        // When: extract_barrel_re_exports is called
1594        let extractor = PythonExtractor::new();
1595        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1596
1597        // Then: one entry with symbols=["Foo"], from_specifier="./module"
1598        let entry = result.iter().find(|e| e.from_specifier == "./module");
1599        assert!(entry.is_some(), "./module not found in {:?}", result);
1600        let entry = entry.unwrap();
1601        assert!(
1602            entry.symbols.contains(&"Foo".to_string()),
1603            "Foo not in symbols: {:?}",
1604            entry.symbols
1605        );
1606    }
1607
1608    // -----------------------------------------------------------------------
1609    // PY-BARREL-03: __all__ = ["Foo"] -> file_exports_any_symbol(["Foo"]) = true
1610    // -----------------------------------------------------------------------
1611    #[test]
1612    fn py_barrel_03_all_exports_symbol_present() {
1613        // Given: a file with __all__ = ["Foo"]
1614        // (we use the fixture file)
1615        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1616            .parent()
1617            .unwrap()
1618            .parent()
1619            .unwrap()
1620            .join("tests/fixtures/python/observe/barrel/__init__.py");
1621
1622        // When: file_exports_any_symbol is called with ["Foo"]
1623        let extractor = PythonExtractor::new();
1624        let symbols = vec!["Foo".to_string()];
1625        let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1626
1627        // Then: returns true
1628        assert!(
1629            result,
1630            "expected file_exports_any_symbol to return true for Foo"
1631        );
1632    }
1633
1634    // -----------------------------------------------------------------------
1635    // PY-BARREL-04: __all__ = ["Foo"] -> file_exports_any_symbol(["Bar"]) = false
1636    // -----------------------------------------------------------------------
1637    #[test]
1638    fn py_barrel_04_all_exports_symbol_absent() {
1639        // Given: a file with __all__ = ["Foo"]
1640        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1641            .parent()
1642            .unwrap()
1643            .parent()
1644            .unwrap()
1645            .join("tests/fixtures/python/observe/barrel/__init__.py");
1646
1647        // When: file_exports_any_symbol is called with ["Bar"]
1648        let extractor = PythonExtractor::new();
1649        let symbols = vec!["Bar".to_string()];
1650        let result = extractor.file_exports_any_symbol(&fixture_path, &symbols);
1651
1652        // Then: returns false
1653        assert!(
1654            !result,
1655            "expected file_exports_any_symbol to return false for Bar"
1656        );
1657    }
1658
1659    // -----------------------------------------------------------------------
1660    // PY-BARREL-05: `from .module import *` extracts wildcard=true
1661    // -----------------------------------------------------------------------
1662    #[test]
1663    fn py_barrel_05_re_export_wildcard() {
1664        // Given: __init__.py content with a wildcard re-export
1665        let source = "from .module import *\n";
1666
1667        // When: extract_barrel_re_exports is called
1668        let extractor = PythonExtractor::new();
1669        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1670
1671        // Then: one entry with wildcard=true, from_specifier="./module", empty symbols
1672        let entry = result.iter().find(|e| e.from_specifier == "./module");
1673        assert!(entry.is_some(), "./module not found in {:?}", result);
1674        let entry = entry.unwrap();
1675        assert!(entry.wildcard, "expected wildcard=true, got {:?}", entry);
1676        assert!(
1677            entry.symbols.is_empty(),
1678            "expected empty symbols for wildcard, got {:?}",
1679            entry.symbols
1680        );
1681    }
1682
1683    // -----------------------------------------------------------------------
1684    // PY-BARREL-06: `from .module import Foo, Bar` extracts named (wildcard=false)
1685    // -----------------------------------------------------------------------
1686    #[test]
1687    fn py_barrel_06_re_export_named_multi_symbol() {
1688        // Given: __init__.py content with multiple named re-exports
1689        let source = "from .module import Foo, Bar\n";
1690
1691        // When: extract_barrel_re_exports is called
1692        let extractor = PythonExtractor::new();
1693        let result = extractor.extract_barrel_re_exports(source, "__init__.py");
1694
1695        // Then: one entry with wildcard=false, symbols=["Foo", "Bar"]
1696        let entry = result.iter().find(|e| e.from_specifier == "./module");
1697        assert!(entry.is_some(), "./module not found in {:?}", result);
1698        let entry = entry.unwrap();
1699        assert!(
1700            !entry.wildcard,
1701            "expected wildcard=false for named re-export, got {:?}",
1702            entry
1703        );
1704        assert!(
1705            entry.symbols.contains(&"Foo".to_string()),
1706            "Foo not in symbols: {:?}",
1707            entry.symbols
1708        );
1709        assert!(
1710            entry.symbols.contains(&"Bar".to_string()),
1711            "Bar not in symbols: {:?}",
1712            entry.symbols
1713        );
1714    }
1715
1716    // -----------------------------------------------------------------------
1717    // PY-BARREL-07: e2e: wildcard barrel resolves imported symbol
1718    // test imports `from pkg import Foo`, pkg/__init__.py has `from .module import *`,
1719    // pkg/module.py defines Foo → mapped
1720    // -----------------------------------------------------------------------
1721    #[test]
1722    fn py_barrel_07_e2e_wildcard_barrel_mapped() {
1723        use tempfile::TempDir;
1724
1725        let dir = TempDir::new().unwrap();
1726        let pkg = dir.path().join("pkg");
1727        std::fs::create_dir_all(&pkg).unwrap();
1728
1729        // pkg/__init__.py: wildcard re-export
1730        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1731        // pkg/module.py: defines Foo
1732        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
1733        // tests/test_foo.py: imports from pkg
1734        let tests_dir = dir.path().join("tests");
1735        std::fs::create_dir_all(&tests_dir).unwrap();
1736        std::fs::write(
1737            tests_dir.join("test_foo.py"),
1738            "from pkg import Foo\n\ndef test_foo():\n    assert Foo()\n",
1739        )
1740        .unwrap();
1741
1742        let extractor = PythonExtractor::new();
1743        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1744        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1745        let test_source = std::fs::read_to_string(&test_path).unwrap();
1746
1747        let production_files = vec![module_path.clone()];
1748        let test_sources: HashMap<String, String> =
1749            [(test_path.clone(), test_source)].into_iter().collect();
1750
1751        // When: map_test_files_with_imports
1752        let result =
1753            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1754
1755        // Then: module.py is matched to test_foo.py via barrel chain
1756        let mapping = result.iter().find(|m| m.production_file == module_path);
1757        assert!(
1758            mapping.is_some(),
1759            "module.py not found in mappings: {:?}",
1760            result
1761        );
1762        let mapping = mapping.unwrap();
1763        assert!(
1764            mapping.test_files.contains(&test_path),
1765            "test_foo.py not matched to module.py: {:?}",
1766            mapping.test_files
1767        );
1768    }
1769
1770    // -----------------------------------------------------------------------
1771    // PY-BARREL-08: e2e: named barrel resolves imported symbol
1772    // test imports `from pkg import Foo`, pkg/__init__.py has `from .module import Foo`,
1773    // pkg/module.py defines Foo → mapped
1774    // -----------------------------------------------------------------------
1775    #[test]
1776    fn py_barrel_08_e2e_named_barrel_mapped() {
1777        use tempfile::TempDir;
1778
1779        let dir = TempDir::new().unwrap();
1780        let pkg = dir.path().join("pkg");
1781        std::fs::create_dir_all(&pkg).unwrap();
1782
1783        // pkg/__init__.py: named re-export
1784        std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
1785        // pkg/module.py: defines Foo
1786        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
1787        // tests/test_foo.py: imports from pkg
1788        let tests_dir = dir.path().join("tests");
1789        std::fs::create_dir_all(&tests_dir).unwrap();
1790        std::fs::write(
1791            tests_dir.join("test_foo.py"),
1792            "from pkg import Foo\n\ndef test_foo():\n    assert Foo()\n",
1793        )
1794        .unwrap();
1795
1796        let extractor = PythonExtractor::new();
1797        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1798        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
1799        let test_source = std::fs::read_to_string(&test_path).unwrap();
1800
1801        let production_files = vec![module_path.clone()];
1802        let test_sources: HashMap<String, String> =
1803            [(test_path.clone(), test_source)].into_iter().collect();
1804
1805        // When: map_test_files_with_imports
1806        let result =
1807            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1808
1809        // Then: module.py is matched to test_foo.py via barrel chain
1810        let mapping = result.iter().find(|m| m.production_file == module_path);
1811        assert!(
1812            mapping.is_some(),
1813            "module.py not found in mappings: {:?}",
1814            result
1815        );
1816        let mapping = mapping.unwrap();
1817        assert!(
1818            mapping.test_files.contains(&test_path),
1819            "test_foo.py not matched to module.py: {:?}",
1820            mapping.test_files
1821        );
1822    }
1823
1824    // -----------------------------------------------------------------------
1825    // PY-BARREL-09: e2e: wildcard barrel does NOT map non-exported symbol
1826    // test imports `from pkg import NonExistent`, pkg/__init__.py has `from .module import *`,
1827    // pkg/module.py has __all__ = ["Foo"] (does NOT export NonExistent) → NOT mapped
1828    // -----------------------------------------------------------------------
1829    #[test]
1830    fn py_barrel_09_e2e_wildcard_barrel_non_exported_not_mapped() {
1831        use tempfile::TempDir;
1832
1833        let dir = TempDir::new().unwrap();
1834        let pkg = dir.path().join("pkg");
1835        std::fs::create_dir_all(&pkg).unwrap();
1836
1837        // pkg/__init__.py: wildcard re-export
1838        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
1839        // pkg/module.py: __all__ explicitly limits exports to Foo only
1840        std::fs::write(
1841            pkg.join("module.py"),
1842            "__all__ = [\"Foo\"]\n\nclass Foo:\n    pass\n\nclass NonExistent:\n    pass\n",
1843        )
1844        .unwrap();
1845        // tests/test_nonexistent.py: imports NonExistent from pkg
1846        let tests_dir = dir.path().join("tests");
1847        std::fs::create_dir_all(&tests_dir).unwrap();
1848        std::fs::write(
1849            tests_dir.join("test_nonexistent.py"),
1850            "from pkg import NonExistent\n\ndef test_ne():\n    assert NonExistent()\n",
1851        )
1852        .unwrap();
1853
1854        let extractor = PythonExtractor::new();
1855        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
1856        let test_path = tests_dir
1857            .join("test_nonexistent.py")
1858            .to_string_lossy()
1859            .into_owned();
1860        let test_source = std::fs::read_to_string(&test_path).unwrap();
1861
1862        let production_files = vec![module_path.clone()];
1863        let test_sources: HashMap<String, String> =
1864            [(test_path.clone(), test_source)].into_iter().collect();
1865
1866        // When: map_test_files_with_imports
1867        let result =
1868            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1869
1870        // Then: module.py is NOT matched to test_nonexistent.py
1871        // (NonExistent is not exported by module.py)
1872        let mapping = result.iter().find(|m| m.production_file == module_path);
1873        if let Some(mapping) = mapping {
1874            assert!(
1875                !mapping.test_files.contains(&test_path),
1876                "test_nonexistent.py should NOT be matched to module.py: {:?}",
1877                mapping.test_files
1878            );
1879        }
1880        // If no mapping found for module.py at all, that's also correct
1881    }
1882
1883    // -----------------------------------------------------------------------
1884    // PY-E2E-01: models.py + test_models.py (same dir) -> Layer 1 match
1885    // -----------------------------------------------------------------------
1886    #[test]
1887    fn py_e2e_01_layer1_stem_match() {
1888        // Given: production file models.py and test file test_models.py in the same directory
1889        let extractor = PythonExtractor::new();
1890        let production_files = vec!["e2e_pkg/models.py".to_string()];
1891        let test_sources: HashMap<String, String> =
1892            [("e2e_pkg/test_models.py".to_string(), "".to_string())]
1893                .into_iter()
1894                .collect();
1895
1896        // When: map_test_files_with_imports is called
1897        let scan_root = PathBuf::from(".");
1898        let result =
1899            extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1900
1901        // Then: models.py is matched to test_models.py via Layer 1 (FileNameConvention)
1902        let mapping = result
1903            .iter()
1904            .find(|m| m.production_file == "e2e_pkg/models.py");
1905        assert!(
1906            mapping.is_some(),
1907            "models.py not found in mappings: {:?}",
1908            result
1909        );
1910        let mapping = mapping.unwrap();
1911        assert!(
1912            mapping
1913                .test_files
1914                .contains(&"e2e_pkg/test_models.py".to_string()),
1915            "test_models.py not in test_files: {:?}",
1916            mapping.test_files
1917        );
1918        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
1919    }
1920
1921    // -----------------------------------------------------------------------
1922    // PY-E2E-02: views.py + test importing `from ..views import index` -> Layer 2 match
1923    // -----------------------------------------------------------------------
1924    #[test]
1925    fn py_e2e_02_layer2_import_tracing() {
1926        // Given: production file views.py and a test that imports from it
1927        let extractor = PythonExtractor::new();
1928
1929        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1930            .parent()
1931            .unwrap()
1932            .parent()
1933            .unwrap()
1934            .join("tests/fixtures/python/observe/e2e_pkg");
1935
1936        let views_path = fixture_root.join("views.py").to_string_lossy().into_owned();
1937        let test_views_path = fixture_root
1938            .join("tests/test_views.py")
1939            .to_string_lossy()
1940            .into_owned();
1941
1942        let test_source =
1943            std::fs::read_to_string(fixture_root.join("tests/test_views.py")).unwrap_or_default();
1944
1945        let production_files = vec![views_path.clone()];
1946        let test_sources: HashMap<String, String> = [(test_views_path.clone(), test_source)]
1947            .into_iter()
1948            .collect();
1949
1950        // When: map_test_files_with_imports is called
1951        let result =
1952            extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
1953
1954        // Then: views.py is matched to test_views.py (Layer 2 or Layer 1)
1955        let mapping = result.iter().find(|m| m.production_file == views_path);
1956        assert!(
1957            mapping.is_some(),
1958            "views.py not found in mappings: {:?}",
1959            result
1960        );
1961        let mapping = mapping.unwrap();
1962        assert!(
1963            mapping.test_files.contains(&test_views_path),
1964            "test_views.py not matched to views.py: {:?}",
1965            mapping.test_files
1966        );
1967    }
1968
1969    // -----------------------------------------------------------------------
1970    // PY-E2E-03: conftest.py is excluded from mapping as helper
1971    // -----------------------------------------------------------------------
1972    #[test]
1973    fn py_e2e_03_conftest_excluded_as_helper() {
1974        // Given: conftest.py alongside test files
1975        let extractor = PythonExtractor::new();
1976        let production_files = vec!["e2e_pkg/models.py".to_string()];
1977        let test_sources: HashMap<String, String> = [
1978            ("e2e_pkg/tests/test_models.py".to_string(), "".to_string()),
1979            (
1980                "e2e_pkg/tests/conftest.py".to_string(),
1981                "import pytest\n".to_string(),
1982            ),
1983        ]
1984        .into_iter()
1985        .collect();
1986
1987        // When: map_test_files_with_imports is called
1988        let scan_root = PathBuf::from(".");
1989        let result =
1990            extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1991
1992        // Then: conftest.py is NOT included in any test_files list
1993        for mapping in &result {
1994            assert!(
1995                !mapping.test_files.iter().any(|f| f.contains("conftest.py")),
1996                "conftest.py should not appear in mappings: {:?}",
1997                mapping
1998            );
1999        }
2000    }
2001
2002    // -----------------------------------------------------------------------
2003    // Helper: setup tempdir with files and run map_test_files_with_imports
2004    // -----------------------------------------------------------------------
2005
2006    struct ImportTestResult {
2007        mappings: Vec<FileMapping>,
2008        prod_path: String,
2009        test_path: String,
2010        _tmp: tempfile::TempDir,
2011    }
2012
2013    /// Create a tempdir with one production file and one test file, then run
2014    /// `map_test_files_with_imports`. `extra_files` are written but not included
2015    /// in `production_files` or `test_sources` (e.g. `__init__.py`).
2016    fn run_import_test(
2017        prod_rel: &str,
2018        prod_content: &str,
2019        test_rel: &str,
2020        test_content: &str,
2021        extra_files: &[(&str, &str)],
2022    ) -> ImportTestResult {
2023        let tmp = tempfile::tempdir().unwrap();
2024
2025        // Write extra files first (e.g. __init__.py)
2026        for (rel, content) in extra_files {
2027            let path = tmp.path().join(rel);
2028            if let Some(parent) = path.parent() {
2029                std::fs::create_dir_all(parent).unwrap();
2030            }
2031            std::fs::write(&path, content).unwrap();
2032        }
2033
2034        // Write production file
2035        let prod_abs = tmp.path().join(prod_rel);
2036        if let Some(parent) = prod_abs.parent() {
2037            std::fs::create_dir_all(parent).unwrap();
2038        }
2039        std::fs::write(&prod_abs, prod_content).unwrap();
2040
2041        // Write test file
2042        let test_abs = tmp.path().join(test_rel);
2043        if let Some(parent) = test_abs.parent() {
2044            std::fs::create_dir_all(parent).unwrap();
2045        }
2046        std::fs::write(&test_abs, test_content).unwrap();
2047
2048        let extractor = PythonExtractor::new();
2049        let prod_path = prod_abs.to_string_lossy().into_owned();
2050        let test_path = test_abs.to_string_lossy().into_owned();
2051        let production_files = vec![prod_path.clone()];
2052        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
2053            .into_iter()
2054            .collect();
2055
2056        let mappings =
2057            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2058
2059        ImportTestResult {
2060            mappings,
2061            prod_path,
2062            test_path,
2063            _tmp: tmp,
2064        }
2065    }
2066
2067    // -----------------------------------------------------------------------
2068    // PY-ABS-01: `from models.cars import Car` -> mapped to models/cars.py via Layer 2
2069    // -----------------------------------------------------------------------
2070    #[test]
2071    fn py_abs_01_absolute_import_nested_module() {
2072        // Given: `from models.cars import Car` in tests/unit/test_car.py,
2073        //        models/cars.py exists at scan_root
2074        let r = run_import_test(
2075            "models/cars.py",
2076            "class Car:\n    pass\n",
2077            "tests/unit/test_car.py",
2078            "from models.cars import Car\n\ndef test_car():\n    pass\n",
2079            &[],
2080        );
2081
2082        // Then: models/cars.py is mapped to test_car.py via Layer 2 (ImportTracing)
2083        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2084        assert!(
2085            mapping.is_some(),
2086            "models/cars.py not found in mappings: {:?}",
2087            r.mappings
2088        );
2089        let mapping = mapping.unwrap();
2090        assert!(
2091            mapping.test_files.contains(&r.test_path),
2092            "test_car.py not in test_files for models/cars.py: {:?}",
2093            mapping.test_files
2094        );
2095        assert_eq!(
2096            mapping.strategy,
2097            MappingStrategy::ImportTracing,
2098            "expected ImportTracing strategy, got {:?}",
2099            mapping.strategy
2100        );
2101    }
2102
2103    // -----------------------------------------------------------------------
2104    // PY-ABS-02: `from utils.publish_state import ...` -> mapped to utils/publish_state.py
2105    // -----------------------------------------------------------------------
2106    #[test]
2107    fn py_abs_02_absolute_import_utils_module() {
2108        // Given: `from utils.publish_state import PublishState` in tests/test_pub.py,
2109        //        utils/publish_state.py exists at scan_root
2110        let r = run_import_test(
2111            "utils/publish_state.py",
2112            "class PublishState:\n    pass\n",
2113            "tests/test_pub.py",
2114            "from utils.publish_state import PublishState\n\ndef test_pub():\n    pass\n",
2115            &[],
2116        );
2117
2118        // Then: utils/publish_state.py is mapped to test_pub.py via Layer 2
2119        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2120        assert!(
2121            mapping.is_some(),
2122            "utils/publish_state.py not found in mappings: {:?}",
2123            r.mappings
2124        );
2125        let mapping = mapping.unwrap();
2126        assert!(
2127            mapping.test_files.contains(&r.test_path),
2128            "test_pub.py not in test_files for utils/publish_state.py: {:?}",
2129            mapping.test_files
2130        );
2131        assert_eq!(
2132            mapping.strategy,
2133            MappingStrategy::ImportTracing,
2134            "expected ImportTracing strategy, got {:?}",
2135            mapping.strategy
2136        );
2137    }
2138
2139    // -----------------------------------------------------------------------
2140    // PY-ABS-03: relative import `from .models import X` -> resolves from from_file parent
2141    // -----------------------------------------------------------------------
2142    #[test]
2143    fn py_abs_03_relative_import_still_resolves() {
2144        // Given: `from .models import X` in pkg/test_something.py,
2145        //        pkg/models.py exists relative to test file
2146        // Note: production file must NOT be inside tests/ dir (Phase 20: tests/ files are helpers)
2147        let r = run_import_test(
2148            "pkg/models.py",
2149            "class X:\n    pass\n",
2150            "pkg/test_something.py",
2151            "from .models import X\n\ndef test_x():\n    pass\n",
2152            &[],
2153        );
2154
2155        // Then: models.py is mapped to test_something.py (relative import resolves from parent dir)
2156        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2157        assert!(
2158            mapping.is_some(),
2159            "pkg/models.py not found in mappings: {:?}",
2160            r.mappings
2161        );
2162        let mapping = mapping.unwrap();
2163        assert!(
2164            mapping.test_files.contains(&r.test_path),
2165            "test_something.py not in test_files for pkg/models.py: {:?}",
2166            mapping.test_files
2167        );
2168    }
2169
2170    // -----------------------------------------------------------------------
2171    // PY-STEM-07: _decoders.py -> production_stem strips single leading underscore
2172    // -----------------------------------------------------------------------
2173    #[test]
2174    fn py_stem_07_production_stem_single_underscore_prefix() {
2175        // Given: production file path "httpx/_decoders.py"
2176        // When: production_stem() is called
2177        // Then: returns Some("decoders") (single leading underscore stripped)
2178        let extractor = PythonExtractor::new();
2179        let result = extractor.production_stem("httpx/_decoders.py");
2180        assert_eq!(result, Some("decoders"));
2181    }
2182
2183    // -----------------------------------------------------------------------
2184    // PY-STEM-08: __version__.py -> production_stem strips only one underscore
2185    // -----------------------------------------------------------------------
2186    #[test]
2187    fn py_stem_08_production_stem_double_underscore_strips_one() {
2188        // Given: production file path "httpx/__version__.py"
2189        // When: production_stem() is called
2190        // Then: returns Some("_version") (only one leading underscore stripped, not __init__ which returns None)
2191        let extractor = PythonExtractor::new();
2192        let result = extractor.production_stem("httpx/__version__.py");
2193        assert_eq!(result, Some("_version"));
2194    }
2195
2196    // -----------------------------------------------------------------------
2197    // PY-STEM-09: decoders.py -> production_stem unchanged (regression)
2198    // -----------------------------------------------------------------------
2199    #[test]
2200    fn py_stem_09_production_stem_no_prefix_regression() {
2201        // Given: production file path "httpx/decoders.py" (no underscore prefix)
2202        // When: production_stem() is called
2203        // Then: returns Some("decoders") (unchanged, no regression)
2204        let extractor = PythonExtractor::new();
2205        let result = extractor.production_stem("httpx/decoders.py");
2206        assert_eq!(result, Some("decoders"));
2207    }
2208
2209    // -----------------------------------------------------------------------
2210    // PY-STEM-10: ___triple.py -> production_stem strips one underscore
2211    // -----------------------------------------------------------------------
2212    #[test]
2213    fn py_stem_10_production_stem_triple_underscore() {
2214        // Given: production file path "pkg/___triple.py"
2215        // When: production_stem() is called
2216        // Then: returns Some("__triple") (one leading underscore stripped)
2217        let extractor = PythonExtractor::new();
2218        let result = extractor.production_stem("pkg/___triple.py");
2219        assert_eq!(result, Some("__triple"));
2220    }
2221
2222    // -----------------------------------------------------------------------
2223    // PY-STEM-11: ___foo__.py -> strip_prefix + strip_suffix chained
2224    // -----------------------------------------------------------------------
2225    #[test]
2226    fn py_stem_11_production_stem_prefix_and_suffix_chained() {
2227        // Given: production file path "pkg/___foo__.py"
2228        // When: production_stem() is called
2229        // Then: returns Some("__foo") (strip_prefix('_') -> "__foo__", strip_suffix("__") -> "__foo")
2230        let extractor = PythonExtractor::new();
2231        let result = extractor.production_stem("pkg/___foo__.py");
2232        assert_eq!(result, Some("__foo"));
2233    }
2234
2235    // -----------------------------------------------------------------------
2236    // PY-STEM-12: __foo__.py -> strip_prefix + strip_suffix (double underscore prefix)
2237    // -----------------------------------------------------------------------
2238    #[test]
2239    fn py_stem_12_production_stem_dunder_prefix_and_suffix() {
2240        // Given: production file path "pkg/__foo__.py"
2241        // When: production_stem() is called
2242        // Then: returns Some("_foo") (strip_prefix('_') -> "_foo__", strip_suffix("__") -> "_foo")
2243        let extractor = PythonExtractor::new();
2244        let result = extractor.production_stem("pkg/__foo__.py");
2245        assert_eq!(result, Some("_foo"));
2246    }
2247
2248    // -----------------------------------------------------------------------
2249    // PY-SRCLAYOUT-01: src/ layout absolute import resolved
2250    // -----------------------------------------------------------------------
2251    #[test]
2252    fn py_srclayout_01_src_layout_absolute_import_resolved() {
2253        // Given: tempdir with "src/mypackage/__init__.py" + "src/mypackage/sessions.py"
2254        //        and test file "tests/test_sessions.py" containing "from mypackage.sessions import Session"
2255        let r = run_import_test(
2256            "src/mypackage/sessions.py",
2257            "class Session:\n    pass\n",
2258            "tests/test_sessions.py",
2259            "from mypackage.sessions import Session\n\ndef test_session():\n    pass\n",
2260            &[("src/mypackage/__init__.py", "")],
2261        );
2262
2263        // Then: sessions.py is in test_files for test_sessions.py.
2264        // Layer 1 core does not match because prod dir (src/mypackage) != test dir (tests),
2265        // but stem-only fallback matches via stem "sessions" (cross-directory).
2266        // Strategy remains FileNameConvention (L1 fallback is still L1).
2267        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2268        assert!(
2269            mapping.is_some(),
2270            "src/mypackage/sessions.py not found in mappings: {:?}",
2271            r.mappings
2272        );
2273        let mapping = mapping.unwrap();
2274        assert!(
2275            mapping.test_files.contains(&r.test_path),
2276            "test_sessions.py not in test_files for sessions.py (src/ layout): {:?}",
2277            mapping.test_files
2278        );
2279        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2280    }
2281
2282    // -----------------------------------------------------------------------
2283    // PY-SRCLAYOUT-02: non-src layout still works (regression)
2284    // -----------------------------------------------------------------------
2285    #[test]
2286    fn py_srclayout_02_non_src_layout_regression() {
2287        // Given: tempdir with "mypackage/sessions.py"
2288        //        and test file "tests/test_sessions.py" containing "from mypackage.sessions import Session"
2289        let r = run_import_test(
2290            "mypackage/sessions.py",
2291            "class Session:\n    pass\n",
2292            "tests/test_sessions.py",
2293            "from mypackage.sessions import Session\n\ndef test_session():\n    pass\n",
2294            &[],
2295        );
2296
2297        // Then: sessions.py is in test_files for test_sessions.py (non-src layout still works).
2298        // Layer 1 core does not match because prod dir (mypackage) != test dir (tests),
2299        // but stem-only fallback matches via stem "sessions" (cross-directory).
2300        // Strategy remains FileNameConvention (L1 fallback is still L1).
2301        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2302        assert!(
2303            mapping.is_some(),
2304            "mypackage/sessions.py not found in mappings: {:?}",
2305            r.mappings
2306        );
2307        let mapping = mapping.unwrap();
2308        assert!(
2309            mapping.test_files.contains(&r.test_path),
2310            "test_sessions.py not in test_files for sessions.py (non-src layout): {:?}",
2311            mapping.test_files
2312        );
2313        assert_eq!(mapping.strategy, MappingStrategy::FileNameConvention);
2314    }
2315
2316    // -----------------------------------------------------------------------
2317    // PY-ABS-04: `from nonexistent.module import X` -> no mapping added (graceful skip)
2318    // -----------------------------------------------------------------------
2319    #[test]
2320    fn py_abs_04_nonexistent_absolute_import_skipped() {
2321        // Given: `from nonexistent.module import X` in test file,
2322        //        nonexistent/module.py does NOT exist at scan_root.
2323        //        models/real.py exists as production file but is NOT imported.
2324        let r = run_import_test(
2325            "models/real.py",
2326            "class Real:\n    pass\n",
2327            "tests/test_missing.py",
2328            "from nonexistent.module import X\n\ndef test_x():\n    pass\n",
2329            &[],
2330        );
2331
2332        // Then: test_missing.py is NOT mapped to models/real.py (unresolvable import skipped)
2333        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2334        if let Some(mapping) = mapping {
2335            assert!(
2336                !mapping.test_files.contains(&r.test_path),
2337                "test_missing.py should NOT be mapped to models/real.py: {:?}",
2338                mapping.test_files
2339            );
2340        }
2341        // passing if no mapping or test_path not in mapping
2342    }
2343
2344    // -----------------------------------------------------------------------
2345    // PY-ABS-05: absolute import in test file maps to production file outside tests/
2346    // -----------------------------------------------------------------------
2347    #[test]
2348    fn py_abs_05_mixed_absolute_and_relative_imports() {
2349        // Given: a test file with `from models.cars import Car` (absolute),
2350        //        models/cars.py exists at scan_root,
2351        //        tests/helpers.py also exists but is a test helper (Phase 20: excluded)
2352        let tmp = tempfile::tempdir().unwrap();
2353        let models_dir = tmp.path().join("models");
2354        let tests_dir = tmp.path().join("tests");
2355        std::fs::create_dir_all(&models_dir).unwrap();
2356        std::fs::create_dir_all(&tests_dir).unwrap();
2357
2358        let cars_py = models_dir.join("cars.py");
2359        std::fs::write(&cars_py, "class Car:\n    pass\n").unwrap();
2360
2361        let helpers_py = tests_dir.join("helpers.py");
2362        std::fs::write(&helpers_py, "def setup():\n    pass\n").unwrap();
2363
2364        let test_py = tests_dir.join("test_mixed.py");
2365        let test_source =
2366            "from models.cars import Car\nfrom .helpers import setup\n\ndef test_mixed():\n    pass\n";
2367        std::fs::write(&test_py, test_source).unwrap();
2368
2369        let extractor = PythonExtractor::new();
2370        let cars_prod = cars_py.to_string_lossy().into_owned();
2371        let helpers_prod = helpers_py.to_string_lossy().into_owned();
2372        let test_path = test_py.to_string_lossy().into_owned();
2373
2374        let production_files = vec![cars_prod.clone(), helpers_prod.clone()];
2375        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2376            .into_iter()
2377            .collect();
2378
2379        // When: map_test_files_with_imports is called
2380        let result =
2381            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2382
2383        // Then: models/cars.py is mapped via absolute import (Layer 2)
2384        let cars_mapping = result.iter().find(|m| m.production_file == cars_prod);
2385        assert!(
2386            cars_mapping.is_some(),
2387            "models/cars.py not found in mappings: {:?}",
2388            result
2389        );
2390        let cars_m = cars_mapping.unwrap();
2391        assert!(
2392            cars_m.test_files.contains(&test_path),
2393            "test_mixed.py not mapped to models/cars.py via absolute import: {:?}",
2394            cars_m.test_files
2395        );
2396
2397        // Then: tests/helpers.py should NOT appear in mappings (Phase 20: tests/ dir files are helpers)
2398        let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
2399        assert!(
2400            helpers_mapping.is_none(),
2401            "tests/helpers.py should be excluded as test helper (Phase 20), but found in mappings: {:?}",
2402            helpers_mapping
2403        );
2404    }
2405
2406    // -----------------------------------------------------------------------
2407    // PY-REL-01: `from .. import X` bare two-dot relative import
2408    // -----------------------------------------------------------------------
2409    #[test]
2410    fn py_rel_01_bare_two_dot_relative_import() {
2411        // Given: `from .. import utils` in pkg/sub/test_thing.py,
2412        //        pkg/utils.py exists (parent of parent)
2413        let r = run_import_test(
2414            "pkg/utils.py",
2415            "def helper():\n    pass\n",
2416            "pkg/sub/test_thing.py",
2417            "from .. import utils\n\ndef test_thing():\n    pass\n",
2418            &[],
2419        );
2420
2421        // Then: pkg/utils.py is mapped via bare relative import (is_bare_relative=true path)
2422        let mapping = r.mappings.iter().find(|m| m.production_file == r.prod_path);
2423        assert!(
2424            mapping.is_some(),
2425            "pkg/utils.py not found in mappings: {:?}",
2426            r.mappings
2427        );
2428        let mapping = mapping.unwrap();
2429        assert!(
2430            mapping.test_files.contains(&r.test_path),
2431            "test_thing.py not in test_files for pkg/utils.py via bare two-dot import: {:?}",
2432            mapping.test_files
2433        );
2434    }
2435}
2436
2437// ---------------------------------------------------------------------------
2438// Route extraction
2439// ---------------------------------------------------------------------------
2440
2441const ROUTE_DECORATOR_QUERY: &str = include_str!("../queries/route_decorator.scm");
2442static ROUTE_DECORATOR_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2443
2444const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
2445
2446/// A route extracted from a FastAPI application.
2447#[derive(Debug, Clone, PartialEq)]
2448pub struct Route {
2449    pub http_method: String,
2450    pub path: String,
2451    pub handler_name: String,
2452    pub file: String,
2453}
2454
2455/// Extract `router = APIRouter(prefix="...")` assignments from source.
2456/// Returns a HashMap from variable name to prefix string.
2457fn collect_router_prefixes(
2458    source_bytes: &[u8],
2459    tree: &tree_sitter::Tree,
2460) -> HashMap<String, String> {
2461    let mut prefixes = HashMap::new();
2462
2463    // Walk the tree to find: assignment where right side is APIRouter(prefix="...")
2464    let root = tree.root_node();
2465    let mut stack = vec![root];
2466
2467    while let Some(node) = stack.pop() {
2468        if node.kind() == "assignment" {
2469            let left = node.child_by_field_name("left");
2470            let right = node.child_by_field_name("right");
2471
2472            if let (Some(left_node), Some(right_node)) = (left, right) {
2473                if left_node.kind() == "identifier" && right_node.kind() == "call" {
2474                    let var_name = left_node.utf8_text(source_bytes).unwrap_or("").to_string();
2475
2476                    // Check if the call is APIRouter(...)
2477                    let fn_node = right_node.child_by_field_name("function");
2478                    let is_api_router = fn_node
2479                        .and_then(|f| f.utf8_text(source_bytes).ok())
2480                        .map(|name| name == "APIRouter")
2481                        .unwrap_or(false);
2482
2483                    if is_api_router {
2484                        // Look for prefix keyword argument
2485                        let args_node = right_node.child_by_field_name("arguments");
2486                        if let Some(args) = args_node {
2487                            let mut args_cursor = args.walk();
2488                            for arg in args.named_children(&mut args_cursor) {
2489                                if arg.kind() == "keyword_argument" {
2490                                    let kw_name = arg
2491                                        .child_by_field_name("name")
2492                                        .and_then(|n| n.utf8_text(source_bytes).ok())
2493                                        .unwrap_or("");
2494                                    if kw_name == "prefix" {
2495                                        if let Some(val) = arg.child_by_field_name("value") {
2496                                            if val.kind() == "string" {
2497                                                let raw = val.utf8_text(source_bytes).unwrap_or("");
2498                                                let prefix = strip_string_quotes(raw);
2499                                                prefixes.insert(var_name.clone(), prefix);
2500                                            }
2501                                        }
2502                                    }
2503                                }
2504                            }
2505                        }
2506                        // If no prefix found, insert empty string (APIRouter() without prefix)
2507                        prefixes.entry(var_name).or_default();
2508                    }
2509                }
2510            }
2511        }
2512
2513        // Push children in reverse order so they are popped in source order (DFS)
2514        let mut w = node.walk();
2515        let children: Vec<_> = node.named_children(&mut w).collect();
2516        for child in children.into_iter().rev() {
2517            stack.push(child);
2518        }
2519    }
2520
2521    prefixes
2522}
2523
2524/// Strip surrounding quotes from a Python string literal.
2525/// `"'/users'"` → `"/users"`, `'"hello"'` → `"hello"`, triple-quoted too.
2526/// Also handles Python string prefixes: r"...", b"...", f"...", u"...", rb"...", etc.
2527///
2528/// Precondition: `raw` must be a tree-sitter `string` node text (always includes quotes after prefix).
2529fn strip_string_quotes(raw: &str) -> String {
2530    // Strip Python string prefix characters (r, b, f, u and combinations thereof).
2531    // Safe because tree-sitter string nodes always have surrounding quotes after the prefix.
2532    let raw = raw.trim_start_matches(|c: char| "rRbBfFuU".contains(c));
2533    // Try triple quotes first
2534    for q in &[r#"""""#, "'''"] {
2535        if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2536            return inner.to_string();
2537        }
2538    }
2539    // Single quotes
2540    for q in &["\"", "'"] {
2541        if let Some(inner) = raw.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
2542            return inner.to_string();
2543        }
2544    }
2545    raw.to_string()
2546}
2547
2548/// Extract FastAPI routes from Python source code.
2549pub fn extract_routes(source: &str, file_path: &str) -> Vec<Route> {
2550    if source.is_empty() {
2551        return Vec::new();
2552    }
2553
2554    let mut parser = PythonExtractor::parser();
2555    let tree = match parser.parse(source, None) {
2556        Some(t) => t,
2557        None => return Vec::new(),
2558    };
2559    let source_bytes = source.as_bytes();
2560
2561    // Pass 1: collect APIRouter prefix assignments
2562    let router_prefixes = collect_router_prefixes(source_bytes, &tree);
2563
2564    // Pass 2: run route_decorator query
2565    let query = cached_query(&ROUTE_DECORATOR_QUERY_CACHE, ROUTE_DECORATOR_QUERY);
2566
2567    let obj_idx = query.capture_index_for_name("route.object");
2568    let method_idx = query.capture_index_for_name("route.method");
2569    let path_idx = query.capture_index_for_name("route.path");
2570    let handler_idx = query.capture_index_for_name("route.handler");
2571
2572    let mut cursor = QueryCursor::new();
2573    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2574
2575    let mut routes = Vec::new();
2576    let mut seen = HashSet::new();
2577
2578    while let Some(m) = matches.next() {
2579        let mut obj: Option<String> = None;
2580        let mut method: Option<String> = None;
2581        let mut path_raw: Option<String> = None;
2582        let mut path_is_string = false;
2583        let mut handler: Option<String> = None;
2584
2585        for cap in m.captures {
2586            let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
2587            if obj_idx == Some(cap.index) {
2588                obj = Some(text);
2589            } else if method_idx == Some(cap.index) {
2590                method = Some(text);
2591            } else if path_idx == Some(cap.index) {
2592                // Determine if it's a string literal or identifier
2593                path_is_string = cap.node.kind() == "string";
2594                path_raw = Some(text);
2595            } else if handler_idx == Some(cap.index) {
2596                handler = Some(text);
2597            }
2598        }
2599
2600        let (obj, method, handler) = match (obj, method, handler) {
2601            (Some(o), Some(m), Some(h)) => (o, m, h),
2602            _ => continue,
2603        };
2604
2605        // Filter: method must be a known HTTP method
2606        if !HTTP_METHODS.contains(&method.as_str()) {
2607            continue;
2608        }
2609
2610        // Resolve path
2611        let sub_path = match path_raw {
2612            Some(ref raw) if path_is_string => strip_string_quotes(raw),
2613            Some(_) => "<dynamic>".to_string(),
2614            None => "<dynamic>".to_string(),
2615        };
2616
2617        // Resolve prefix from router variable
2618        let prefix = router_prefixes.get(&obj).map(|s| s.as_str()).unwrap_or("");
2619        let full_path = if prefix.is_empty() {
2620            sub_path
2621        } else {
2622            format!("{prefix}{sub_path}")
2623        };
2624
2625        // Deduplicate: same (method, path, handler)
2626        let key = (method.clone(), full_path.clone(), handler.clone());
2627        if !seen.insert(key) {
2628            continue;
2629        }
2630
2631        routes.push(Route {
2632            http_method: method.to_uppercase(),
2633            path: full_path,
2634            handler_name: handler,
2635            file: file_path.to_string(),
2636        });
2637    }
2638
2639    routes
2640}
2641
2642// ---------------------------------------------------------------------------
2643// Route extraction tests (FA-RT-01 ~ FA-RT-10)
2644// ---------------------------------------------------------------------------
2645
2646#[cfg(test)]
2647mod route_tests {
2648    use super::*;
2649
2650    // FA-RT-01: basic @app.get route
2651    #[test]
2652    fn fa_rt_01_basic_app_get_route() {
2653        // Given: source with `@app.get("/users") def read_users(): ...`
2654        let source = r#"
2655from fastapi import FastAPI
2656app = FastAPI()
2657
2658@app.get("/users")
2659def read_users():
2660    return []
2661"#;
2662
2663        // When: extract_routes(source, "main.py")
2664        let routes = extract_routes(source, "main.py");
2665
2666        // Then: [Route { method: "GET", path: "/users", handler: "read_users" }]
2667        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2668        assert_eq!(routes[0].http_method, "GET");
2669        assert_eq!(routes[0].path, "/users");
2670        assert_eq!(routes[0].handler_name, "read_users");
2671    }
2672
2673    // FA-RT-02: multiple HTTP methods
2674    #[test]
2675    fn fa_rt_02_multiple_http_methods() {
2676        // Given: source with @app.get, @app.post, @app.put, @app.delete on separate functions
2677        let source = r#"
2678from fastapi import FastAPI
2679app = FastAPI()
2680
2681@app.get("/items")
2682def list_items():
2683    return []
2684
2685@app.post("/items")
2686def create_item():
2687    return {}
2688
2689@app.put("/items/{item_id}")
2690def update_item(item_id: int):
2691    return {}
2692
2693@app.delete("/items/{item_id}")
2694def delete_item(item_id: int):
2695    return {}
2696"#;
2697
2698        // When: extract_routes(source, "main.py")
2699        let routes = extract_routes(source, "main.py");
2700
2701        // Then: 4 routes with correct methods
2702        assert_eq!(routes.len(), 4, "expected 4 routes, got {:?}", routes);
2703        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
2704        assert!(methods.contains(&"GET"), "missing GET");
2705        assert!(methods.contains(&"POST"), "missing POST");
2706        assert!(methods.contains(&"PUT"), "missing PUT");
2707        assert!(methods.contains(&"DELETE"), "missing DELETE");
2708    }
2709
2710    // FA-RT-03: path parameter
2711    #[test]
2712    fn fa_rt_03_path_parameter() {
2713        // Given: `@app.get("/items/{item_id}")`
2714        let source = r#"
2715from fastapi import FastAPI
2716app = FastAPI()
2717
2718@app.get("/items/{item_id}")
2719def read_item(item_id: int):
2720    return {}
2721"#;
2722
2723        // When: extract_routes(source, "main.py")
2724        let routes = extract_routes(source, "main.py");
2725
2726        // Then: path = "/items/{item_id}"
2727        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2728        assert_eq!(routes[0].path, "/items/{item_id}");
2729    }
2730
2731    // FA-RT-04: @router.get with APIRouter prefix
2732    #[test]
2733    fn fa_rt_04_router_get_with_prefix() {
2734        // Given: `router = APIRouter(prefix="/items")` + `@router.get("/{item_id}")`
2735        let source = r#"
2736from fastapi import APIRouter
2737
2738router = APIRouter(prefix="/items")
2739
2740@router.get("/{item_id}")
2741def read_item(item_id: int):
2742    return {}
2743"#;
2744
2745        // When: extract_routes(source, "routes.py")
2746        let routes = extract_routes(source, "routes.py");
2747
2748        // Then: path = "/items/{item_id}"
2749        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2750        assert_eq!(
2751            routes[0].path, "/items/{item_id}",
2752            "expected prefix-resolved path"
2753        );
2754    }
2755
2756    // FA-RT-05: @router.get without prefix
2757    #[test]
2758    fn fa_rt_05_router_get_without_prefix() {
2759        // Given: `router = APIRouter()` + `@router.get("/health")`
2760        let source = r#"
2761from fastapi import APIRouter
2762
2763router = APIRouter()
2764
2765@router.get("/health")
2766def health_check():
2767    return {"status": "ok"}
2768"#;
2769
2770        // When: extract_routes(source, "routes.py")
2771        let routes = extract_routes(source, "routes.py");
2772
2773        // Then: path = "/health"
2774        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2775        assert_eq!(routes[0].path, "/health");
2776    }
2777
2778    // FA-RT-06: non-route decorator ignored
2779    #[test]
2780    fn fa_rt_06_non_route_decorator_ignored() {
2781        // Given: `@pytest.fixture` or `@staticmethod` decorated function
2782        let source = r#"
2783import pytest
2784
2785@pytest.fixture
2786def client():
2787    return None
2788
2789class MyClass:
2790    @staticmethod
2791    def helper():
2792        pass
2793"#;
2794
2795        // When: extract_routes(source, "main.py")
2796        let routes = extract_routes(source, "main.py");
2797
2798        // Then: empty Vec
2799        assert!(
2800            routes.is_empty(),
2801            "expected no routes for non-route decorators, got {:?}",
2802            routes
2803        );
2804    }
2805
2806    // FA-RT-07: dynamic path (non-literal)
2807    #[test]
2808    fn fa_rt_07_dynamic_path_non_literal() {
2809        // Given: `@app.get(some_variable)`
2810        let source = r#"
2811from fastapi import FastAPI
2812app = FastAPI()
2813
2814ROUTE_PATH = "/dynamic"
2815
2816@app.get(ROUTE_PATH)
2817def dynamic_route():
2818    return {}
2819"#;
2820
2821        // When: extract_routes(source, "main.py")
2822        let routes = extract_routes(source, "main.py");
2823
2824        // Then: path = "<dynamic>"
2825        assert_eq!(
2826            routes.len(),
2827            1,
2828            "expected 1 route for dynamic path, got {:?}",
2829            routes
2830        );
2831        assert_eq!(
2832            routes[0].path, "<dynamic>",
2833            "expected <dynamic> for non-literal path argument"
2834        );
2835    }
2836
2837    // FA-RT-08: empty source
2838    #[test]
2839    fn fa_rt_08_empty_source() {
2840        // Given: ""
2841        let source = "";
2842
2843        // When: extract_routes(source, "main.py")
2844        let routes = extract_routes(source, "main.py");
2845
2846        // Then: empty Vec
2847        assert!(routes.is_empty(), "expected empty Vec for empty source");
2848    }
2849
2850    // FA-RT-09: async def handler
2851    #[test]
2852    fn fa_rt_09_async_def_handler() {
2853        // Given: `@app.get("/") async def root(): ...`
2854        let source = r#"
2855from fastapi import FastAPI
2856app = FastAPI()
2857
2858@app.get("/")
2859async def root():
2860    return {"message": "hello"}
2861"#;
2862
2863        // When: extract_routes(source, "main.py")
2864        let routes = extract_routes(source, "main.py");
2865
2866        // Then: handler = "root" (async は無視)
2867        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
2868        assert_eq!(
2869            routes[0].handler_name, "root",
2870            "async def should produce handler_name = 'root'"
2871        );
2872    }
2873
2874    // FA-RT-10: multiple decorators on same function
2875    #[test]
2876    fn fa_rt_10_multiple_decorators_on_same_function() {
2877        // Given: `@app.get("/") @require_auth def root(): ...`
2878        let source = r#"
2879from fastapi import FastAPI
2880app = FastAPI()
2881
2882def require_auth(func):
2883    return func
2884
2885@app.get("/")
2886@require_auth
2887def root():
2888    return {}
2889"#;
2890
2891        // When: extract_routes(source, "main.py")
2892        let routes = extract_routes(source, "main.py");
2893
2894        // Then: 1 route (non-route decorators ignored)
2895        assert_eq!(
2896            routes.len(),
2897            1,
2898            "expected exactly 1 route (non-route decorators ignored), got {:?}",
2899            routes
2900        );
2901        assert_eq!(routes[0].http_method, "GET");
2902        assert_eq!(routes[0].path, "/");
2903        assert_eq!(routes[0].handler_name, "root");
2904    }
2905}
2906
2907// ---------------------------------------------------------------------------
2908// Django URL conf route extraction
2909// ---------------------------------------------------------------------------
2910
2911const DJANGO_URL_PATTERN_QUERY: &str = include_str!("../queries/django_url_pattern.scm");
2912static DJANGO_URL_PATTERN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
2913
2914static DJANGO_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
2915static DJANGO_RE_PATH_RE: OnceLock<regex::Regex> = OnceLock::new();
2916
2917const HTTP_METHOD_ANY: &str = "ANY";
2918
2919/// Normalize a Django `path()` URL pattern to Express-style `:param` notation.
2920/// `"users/<int:pk>/"` → `"users/:pk/"`
2921/// `"users/<pk>/"` → `"users/:pk/"`
2922pub fn normalize_django_path(path: &str) -> String {
2923    let re = DJANGO_PATH_RE
2924        .get_or_init(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").expect("invalid regex"));
2925    re.replace_all(path, ":$1").into_owned()
2926}
2927
2928/// Normalize a Django `re_path()` URL pattern.
2929/// Strips leading `^` / trailing `$` anchors and converts `(?P<name>...)` to `:name`.
2930pub fn normalize_re_path(path: &str) -> String {
2931    // Strip leading ^ (only if the very first character is ^)
2932    let s = path.strip_prefix('^').unwrap_or(path);
2933    // Strip trailing $ (only if the very last character is $)
2934    let s = s.strip_suffix('$').unwrap_or(s);
2935    // Replace (?P<name>...) named groups with :name.
2936    // Note: `[^)]*` correctly handles typical Django patterns like `(?P<year>[0-9]{4})`.
2937    // Known limitation: nested parentheses inside a named group (e.g., `(?P<slug>(?:foo|bar))`)
2938    // will not match because `[^)]*` stops at the first `)`. Such patterns are extremely rare
2939    // in Django URL confs and are left as a known constraint.
2940    let re = DJANGO_RE_PATH_RE
2941        .get_or_init(|| regex::Regex::new(r"\(\?P<(\w+)>[^)]*\)").expect("invalid regex"));
2942    re.replace_all(s, ":$1").into_owned()
2943}
2944
2945/// Extract Django URL conf routes from Python source code.
2946pub fn extract_django_routes(source: &str, file_path: &str) -> Vec<Route> {
2947    if source.is_empty() {
2948        return Vec::new();
2949    }
2950
2951    let mut parser = PythonExtractor::parser();
2952    let tree = match parser.parse(source, None) {
2953        Some(t) => t,
2954        None => return Vec::new(),
2955    };
2956    let source_bytes = source.as_bytes();
2957
2958    let query = cached_query(&DJANGO_URL_PATTERN_QUERY_CACHE, DJANGO_URL_PATTERN_QUERY);
2959
2960    let func_idx = query.capture_index_for_name("django.func");
2961    let path_idx = query.capture_index_for_name("django.path");
2962    let handler_idx = query.capture_index_for_name("django.handler");
2963
2964    let mut cursor = QueryCursor::new();
2965    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
2966
2967    let mut routes = Vec::new();
2968    let mut seen = HashSet::new();
2969
2970    while let Some(m) = matches.next() {
2971        let mut func: Option<String> = None;
2972        let mut path_raw: Option<String> = None;
2973        let mut handler: Option<String> = None;
2974
2975        for cap in m.captures {
2976            let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
2977            if func_idx == Some(cap.index) {
2978                func = Some(text);
2979            } else if path_idx == Some(cap.index) {
2980                path_raw = Some(text);
2981            } else if handler_idx == Some(cap.index) {
2982                handler = Some(text);
2983            }
2984        }
2985
2986        let (func, path_raw, handler) = match (func, path_raw, handler) {
2987            (Some(f), Some(p), Some(h)) => (f, p, h),
2988            _ => continue,
2989        };
2990
2991        let raw_path = strip_string_quotes(&path_raw);
2992        let normalized = match func.as_str() {
2993            "re_path" => normalize_re_path(&raw_path),
2994            _ => normalize_django_path(&raw_path),
2995        };
2996
2997        // Deduplicate: same (method, path, handler)
2998        let key = (
2999            HTTP_METHOD_ANY.to_string(),
3000            normalized.clone(),
3001            handler.clone(),
3002        );
3003        if !seen.insert(key) {
3004            continue;
3005        }
3006
3007        routes.push(Route {
3008            http_method: HTTP_METHOD_ANY.to_string(),
3009            path: normalized,
3010            handler_name: handler,
3011            file: file_path.to_string(),
3012        });
3013    }
3014
3015    routes
3016}
3017
3018// ---------------------------------------------------------------------------
3019// Django route extraction tests (DJ-NP-*, DJ-NR-*, DJ-RT-*, DJ-RT-E2E-*)
3020// ---------------------------------------------------------------------------
3021
3022#[cfg(test)]
3023mod django_route_tests {
3024    use super::*;
3025
3026    // -----------------------------------------------------------------------
3027    // Unit: normalize_django_path
3028    // -----------------------------------------------------------------------
3029
3030    // DJ-NP-01: typed parameter
3031    #[test]
3032    fn dj_np_01_typed_parameter() {
3033        // Given: a Django path with a typed parameter "users/<int:pk>/"
3034        // When: normalize_django_path is called
3035        // Then: returns "users/:pk/"
3036        let result = normalize_django_path("users/<int:pk>/");
3037        assert_eq!(result, "users/:pk/");
3038    }
3039
3040    // DJ-NP-02: untyped parameter
3041    #[test]
3042    fn dj_np_02_untyped_parameter() {
3043        // Given: a Django path with an untyped parameter "users/<pk>/"
3044        // When: normalize_django_path is called
3045        // Then: returns "users/:pk/"
3046        let result = normalize_django_path("users/<pk>/");
3047        assert_eq!(result, "users/:pk/");
3048    }
3049
3050    // DJ-NP-03: multiple parameters
3051    #[test]
3052    fn dj_np_03_multiple_parameters() {
3053        // Given: a Django path with multiple parameters
3054        // When: normalize_django_path is called
3055        // Then: returns "posts/:slug/comments/:id/"
3056        let result = normalize_django_path("posts/<slug:slug>/comments/<int:id>/");
3057        assert_eq!(result, "posts/:slug/comments/:id/");
3058    }
3059
3060    // DJ-NP-04: no parameters
3061    #[test]
3062    fn dj_np_04_no_parameters() {
3063        // Given: a Django path with no parameters "users/"
3064        // When: normalize_django_path is called
3065        // Then: returns "users/" unchanged
3066        let result = normalize_django_path("users/");
3067        assert_eq!(result, "users/");
3068    }
3069
3070    // -----------------------------------------------------------------------
3071    // Unit: normalize_re_path
3072    // -----------------------------------------------------------------------
3073
3074    // DJ-NR-01: single named group
3075    #[test]
3076    fn dj_nr_01_single_named_group() {
3077        // Given: a re_path pattern with one named group
3078        // When: normalize_re_path is called
3079        // Then: returns "articles/:year/"
3080        let result = normalize_re_path("^articles/(?P<year>[0-9]{4})/$");
3081        assert_eq!(result, "articles/:year/");
3082    }
3083
3084    // DJ-NR-02: multiple named groups
3085    #[test]
3086    fn dj_nr_02_multiple_named_groups() {
3087        // Given: a re_path pattern with multiple named groups
3088        // When: normalize_re_path is called
3089        // Then: returns ":year/:month/"
3090        let result = normalize_re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$");
3091        assert_eq!(result, ":year/:month/");
3092    }
3093
3094    // DJ-NR-03: no named groups
3095    #[test]
3096    fn dj_nr_03_no_named_groups() {
3097        // Given: a re_path pattern with no named groups
3098        // When: normalize_re_path is called
3099        // Then: anchor stripped → "users/"
3100        let result = normalize_re_path("^users/$");
3101        assert_eq!(result, "users/");
3102    }
3103
3104    // DJ-NR-04: ^ inside character class must not be stripped
3105    #[test]
3106    fn dj_nr_04_character_class_caret_preserved() {
3107        // Given: a re_path pattern with ^ inside a character class [^/]+
3108        // When: normalize_re_path is called
3109        // Then: the ^ inside [] is NOT treated as an anchor: "items/[^/]+/"
3110        let result = normalize_re_path("^items/[^/]+/$");
3111        assert_eq!(result, "items/[^/]+/");
3112    }
3113
3114    // -----------------------------------------------------------------------
3115    // Unit: extract_django_routes
3116    // -----------------------------------------------------------------------
3117
3118    // DJ-RT-01: basic path() with attribute handler (views.user_list)
3119    #[test]
3120    fn dj_rt_01_basic_path_attribute_handler() {
3121        // Given: urlpatterns with path("users/", views.user_list)
3122        let source = r#"
3123from django.urls import path
3124from . import views
3125
3126urlpatterns = [
3127    path("users/", views.user_list),
3128]
3129"#;
3130        // When: extract_django_routes is called
3131        let routes = extract_django_routes(source, "urls.py");
3132
3133        // Then: 1 route, method="ANY", path="users/", handler="user_list"
3134        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3135        assert_eq!(routes[0].http_method, "ANY");
3136        assert_eq!(routes[0].path, "users/");
3137        assert_eq!(routes[0].handler_name, "user_list");
3138    }
3139
3140    // DJ-RT-02: path() with direct import handler
3141    #[test]
3142    fn dj_rt_02_path_direct_import_handler() {
3143        // Given: urlpatterns with path("users/", user_list) — direct function import
3144        let source = r#"
3145from django.urls import path
3146from .views import user_list
3147
3148urlpatterns = [
3149    path("users/", user_list),
3150]
3151"#;
3152        // When: extract_django_routes is called
3153        let routes = extract_django_routes(source, "urls.py");
3154
3155        // Then: 1 route, method="ANY", path="users/", handler="user_list"
3156        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3157        assert_eq!(routes[0].http_method, "ANY");
3158        assert_eq!(routes[0].path, "users/");
3159        assert_eq!(routes[0].handler_name, "user_list");
3160    }
3161
3162    // DJ-RT-03: path() with typed parameter
3163    #[test]
3164    fn dj_rt_03_path_typed_parameter() {
3165        // Given: path("users/<int:pk>/", views.user_detail)
3166        let source = r#"
3167from django.urls import path
3168from . import views
3169
3170urlpatterns = [
3171    path("users/<int:pk>/", views.user_detail),
3172]
3173"#;
3174        // When: extract_django_routes is called
3175        let routes = extract_django_routes(source, "urls.py");
3176
3177        // Then: path = "users/:pk/"
3178        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3179        assert_eq!(routes[0].path, "users/:pk/");
3180    }
3181
3182    // DJ-RT-04: path() with untyped parameter
3183    #[test]
3184    fn dj_rt_04_path_untyped_parameter() {
3185        // Given: path("users/<pk>/", views.user_detail)
3186        let source = r#"
3187from django.urls import path
3188from . import views
3189
3190urlpatterns = [
3191    path("users/<pk>/", views.user_detail),
3192]
3193"#;
3194        // When: extract_django_routes is called
3195        let routes = extract_django_routes(source, "urls.py");
3196
3197        // Then: path = "users/:pk/"
3198        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3199        assert_eq!(routes[0].path, "users/:pk/");
3200    }
3201
3202    // DJ-RT-05: re_path() with named group
3203    #[test]
3204    fn dj_rt_05_re_path_named_group() {
3205        // Given: re_path("^articles/(?P<year>[0-9]{4})/$", views.year_archive)
3206        let source = r#"
3207from django.urls import re_path
3208from . import views
3209
3210urlpatterns = [
3211    re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive),
3212]
3213"#;
3214        // When: extract_django_routes is called
3215        let routes = extract_django_routes(source, "urls.py");
3216
3217        // Then: path = "articles/:year/"
3218        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3219        assert_eq!(routes[0].path, "articles/:year/");
3220    }
3221
3222    // DJ-RT-06: multiple routes — all method "ANY"
3223    #[test]
3224    fn dj_rt_06_multiple_routes() {
3225        // Given: 3 path() entries in urlpatterns
3226        let source = r#"
3227from django.urls import path
3228from . import views
3229
3230urlpatterns = [
3231    path("users/", views.user_list),
3232    path("users/<int:pk>/", views.user_detail),
3233    path("about/", views.about),
3234]
3235"#;
3236        // When: extract_django_routes is called
3237        let routes = extract_django_routes(source, "urls.py");
3238
3239        // Then: 3 routes, all method "ANY"
3240        assert_eq!(routes.len(), 3, "expected 3 routes, got {:?}", routes);
3241        for r in &routes {
3242            assert_eq!(r.http_method, "ANY", "expected method ANY for {:?}", r);
3243        }
3244    }
3245
3246    // DJ-RT-07: path() with name kwarg — name kwarg ignored, handler captured
3247    #[test]
3248    fn dj_rt_07_path_with_name_kwarg() {
3249        // Given: path("login/", views.login_view, name="login")
3250        let source = r#"
3251from django.urls import path
3252from . import views
3253
3254urlpatterns = [
3255    path("login/", views.login_view, name="login"),
3256]
3257"#;
3258        // When: extract_django_routes is called
3259        let routes = extract_django_routes(source, "urls.py");
3260
3261        // Then: 1 route, handler = "login_view" (name kwarg ignored)
3262        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3263        assert_eq!(routes[0].handler_name, "login_view");
3264    }
3265
3266    // DJ-RT-08: empty source
3267    #[test]
3268    fn dj_rt_08_empty_source() {
3269        // Given: ""
3270        // When: extract_django_routes is called
3271        let routes = extract_django_routes("", "urls.py");
3272
3273        // Then: empty Vec
3274        assert!(routes.is_empty(), "expected empty Vec for empty source");
3275    }
3276
3277    // DJ-RT-09: no path/re_path calls
3278    #[test]
3279    fn dj_rt_09_no_path_calls() {
3280        // Given: source with no path() or re_path() calls
3281        let source = r#"
3282from django.db import models
3283
3284class User(models.Model):
3285    name = models.CharField(max_length=100)
3286"#;
3287        // When: extract_django_routes is called
3288        let routes = extract_django_routes(source, "models.py");
3289
3290        // Then: empty Vec
3291        assert!(
3292            routes.is_empty(),
3293            "expected empty Vec for non-URL source, got {:?}",
3294            routes
3295        );
3296    }
3297
3298    // DJ-RT-10: deduplication — same (path, handler) appears twice → 1 route
3299    #[test]
3300    fn dj_rt_10_deduplication() {
3301        // Given: two identical path() entries
3302        let source = r#"
3303from django.urls import path
3304from . import views
3305
3306urlpatterns = [
3307    path("users/", views.user_list),
3308    path("users/", views.user_list),
3309]
3310"#;
3311        // When: extract_django_routes is called
3312        let routes = extract_django_routes(source, "urls.py");
3313
3314        // Then: 1 route (deduplicated)
3315        assert_eq!(
3316            routes.len(),
3317            1,
3318            "expected 1 route after dedup, got {:?}",
3319            routes
3320        );
3321    }
3322
3323    // DJ-RT-11: include() is ignored
3324    #[test]
3325    fn dj_rt_11_include_is_ignored() {
3326        // Given: urlpatterns with include() only
3327        let source = r#"
3328from django.urls import path, include
3329
3330urlpatterns = [
3331    path("api/", include("myapp.urls")),
3332]
3333"#;
3334        // When: extract_django_routes is called
3335        let routes = extract_django_routes(source, "urls.py");
3336
3337        // Then: empty Vec (include() is not a handler)
3338        assert!(
3339            routes.is_empty(),
3340            "expected empty Vec for include()-only urlpatterns, got {:?}",
3341            routes
3342        );
3343    }
3344
3345    // DJ-RT-12: multiple path parameters
3346    #[test]
3347    fn dj_rt_12_multiple_path_parameters() {
3348        // Given: path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail)
3349        let source = r#"
3350from django.urls import path
3351from . import views
3352
3353urlpatterns = [
3354    path("posts/<slug:slug>/comments/<int:id>/", views.comment_detail),
3355]
3356"#;
3357        // When: extract_django_routes is called
3358        let routes = extract_django_routes(source, "urls.py");
3359
3360        // Then: path = "posts/:slug/comments/:id/"
3361        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3362        assert_eq!(routes[0].path, "posts/:slug/comments/:id/");
3363    }
3364
3365    // DJ-RT-13: re_path with multiple named groups
3366    #[test]
3367    fn dj_rt_13_re_path_multiple_named_groups() {
3368        // Given: re_path("^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive)
3369        let source = r#"
3370from django.urls import re_path
3371from . import views
3372
3373urlpatterns = [
3374    re_path(r"^(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.archive),
3375]
3376"#;
3377        // When: extract_django_routes is called
3378        let routes = extract_django_routes(source, "urls.py");
3379
3380        // Then: path = ":year/:month/"
3381        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
3382        assert_eq!(routes[0].path, ":year/:month/");
3383    }
3384
3385    // -----------------------------------------------------------------------
3386    // Integration: CLI (DJ-RT-E2E-01)
3387    // -----------------------------------------------------------------------
3388
3389    // DJ-RT-E2E-01: observe with Django routes — routes_total = 2
3390    #[test]
3391    fn dj_rt_e2e_01_observe_django_routes_coverage() {
3392        use tempfile::TempDir;
3393
3394        // Given: tempdir with urls.py (2 routes) and test_urls.py
3395        let dir = TempDir::new().unwrap();
3396        let urls_py = dir.path().join("urls.py");
3397        let test_urls_py = dir.path().join("test_urls.py");
3398
3399        std::fs::write(
3400            &urls_py,
3401            r#"from django.urls import path
3402from . import views
3403
3404urlpatterns = [
3405    path("users/", views.user_list),
3406    path("users/<int:pk>/", views.user_detail),
3407]
3408"#,
3409        )
3410        .unwrap();
3411
3412        std::fs::write(
3413            &test_urls_py,
3414            r#"def test_user_list():
3415    pass
3416
3417def test_user_detail():
3418    pass
3419"#,
3420        )
3421        .unwrap();
3422
3423        // When: extract_django_routes from urls.py
3424        let urls_source = std::fs::read_to_string(&urls_py).unwrap();
3425        let urls_path = urls_py.to_string_lossy().into_owned();
3426
3427        let routes = extract_django_routes(&urls_source, &urls_path);
3428
3429        // Then: routes_total = 2
3430        assert_eq!(
3431            routes.len(),
3432            2,
3433            "expected 2 routes extracted from urls.py, got {:?}",
3434            routes
3435        );
3436
3437        // Verify both routes have method "ANY"
3438        for r in &routes {
3439            assert_eq!(r.http_method, "ANY", "expected method ANY, got {:?}", r);
3440        }
3441    }
3442
3443    // -----------------------------------------------------------------------
3444    // PY-IMPORT-04: e2e: `import pkg`, pkg/__init__.py has `from .module import *`,
3445    //               pkg/module.py has Foo -> module.py mapped
3446    // -----------------------------------------------------------------------
3447    #[test]
3448    fn py_import_04_e2e_bare_import_wildcard_barrel_mapped() {
3449        use tempfile::TempDir;
3450
3451        // Given: tempdir with pkg/__init__.py (wildcard re-export) + pkg/module.py
3452        //        and test_foo.py that uses bare `import pkg`
3453        let dir = TempDir::new().unwrap();
3454        let pkg = dir.path().join("pkg");
3455        std::fs::create_dir_all(&pkg).unwrap();
3456
3457        std::fs::write(pkg.join("__init__.py"), "from .module import *\n").unwrap();
3458        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
3459
3460        let tests_dir = dir.path().join("tests");
3461        std::fs::create_dir_all(&tests_dir).unwrap();
3462        let test_content = "import pkg\n\ndef test_foo():\n    assert pkg.Foo()\n";
3463        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3464
3465        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3466        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3467
3468        let extractor = PythonExtractor::new();
3469        let production_files = vec![module_path.clone()];
3470        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3471            .into_iter()
3472            .collect();
3473
3474        // When: map_test_files_with_imports is called
3475        let result =
3476            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3477
3478        // Then: module.py is matched via bare import + wildcard barrel chain
3479        let mapping = result.iter().find(|m| m.production_file == module_path);
3480        assert!(
3481            mapping.is_some(),
3482            "module.py not mapped; bare import + wildcard barrel should resolve. mappings={:?}",
3483            result
3484        );
3485        let mapping = mapping.unwrap();
3486        assert!(
3487            mapping.test_files.contains(&test_path),
3488            "test_foo.py not in test_files for module.py: {:?}",
3489            mapping.test_files
3490        );
3491    }
3492
3493    // -----------------------------------------------------------------------
3494    // PY-IMPORT-05: e2e: `import pkg`, pkg/__init__.py has `from .module import Foo`
3495    //               (named), pkg/module.py has Foo -> module.py mapped
3496    // -----------------------------------------------------------------------
3497    #[test]
3498    fn py_import_05_e2e_bare_import_named_barrel_mapped() {
3499        use tempfile::TempDir;
3500
3501        // Given: tempdir with pkg/__init__.py (named re-export) + pkg/module.py
3502        //        and test_foo.py that uses bare `import pkg`
3503        let dir = TempDir::new().unwrap();
3504        let pkg = dir.path().join("pkg");
3505        std::fs::create_dir_all(&pkg).unwrap();
3506
3507        std::fs::write(pkg.join("__init__.py"), "from .module import Foo\n").unwrap();
3508        std::fs::write(pkg.join("module.py"), "class Foo:\n    pass\n").unwrap();
3509
3510        let tests_dir = dir.path().join("tests");
3511        std::fs::create_dir_all(&tests_dir).unwrap();
3512        let test_content = "import pkg\n\ndef test_foo():\n    assert pkg.Foo()\n";
3513        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3514
3515        let module_path = pkg.join("module.py").to_string_lossy().into_owned();
3516        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3517
3518        let extractor = PythonExtractor::new();
3519        let production_files = vec![module_path.clone()];
3520        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3521            .into_iter()
3522            .collect();
3523
3524        // When: map_test_files_with_imports is called
3525        let result =
3526            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3527
3528        // Then: module.py is matched via bare import + named barrel chain
3529        let mapping = result.iter().find(|m| m.production_file == module_path);
3530        assert!(
3531            mapping.is_some(),
3532            "module.py not mapped; bare import + named barrel should resolve. mappings={:?}",
3533            result
3534        );
3535        let mapping = mapping.unwrap();
3536        assert!(
3537            mapping.test_files.contains(&test_path),
3538            "test_foo.py not in test_files for module.py: {:?}",
3539            mapping.test_files
3540        );
3541    }
3542
3543    // -----------------------------------------------------------------------
3544    // PY-ATTR-01: `import httpx\nhttpx.Client()\n`
3545    //             -> specifier="httpx", symbols=["Client"] (single attribute access)
3546    // -----------------------------------------------------------------------
3547    #[test]
3548    fn py_attr_01_bare_import_single_attribute() {
3549        // Given: source with a bare import and a single attribute access
3550        let source = "import httpx\nhttpx.Client()\n";
3551
3552        // When: extract_all_import_specifiers is called
3553        let extractor = PythonExtractor::new();
3554        let result = extractor.extract_all_import_specifiers(source);
3555
3556        // Then: contains ("httpx", ["Client"]) -- attribute access extracted as symbol
3557        let entry = result.iter().find(|(spec, _)| spec == "httpx");
3558        assert!(entry.is_some(), "httpx not found in {:?}", result);
3559        let (_, symbols) = entry.unwrap();
3560        assert_eq!(
3561            symbols,
3562            &vec!["Client".to_string()],
3563            "expected [\"Client\"] for bare import with attribute access, got {:?}",
3564            symbols
3565        );
3566    }
3567
3568    // -----------------------------------------------------------------------
3569    // PY-ATTR-02: `import httpx\nhttpx.Client()\nhttpx.get()\n`
3570    //             -> specifier="httpx", symbols contains "Client" and "get" (multiple attributes)
3571    // -----------------------------------------------------------------------
3572    #[test]
3573    fn py_attr_02_bare_import_multiple_attributes() {
3574        // Given: source with a bare import and multiple attribute accesses
3575        let source = "import httpx\nhttpx.Client()\nhttpx.get()\n";
3576
3577        // When: extract_all_import_specifiers is called
3578        let extractor = PythonExtractor::new();
3579        let result = extractor.extract_all_import_specifiers(source);
3580
3581        // Then: contains ("httpx", [...]) with both "Client" and "get"
3582        let entry = result.iter().find(|(spec, _)| spec == "httpx");
3583        assert!(entry.is_some(), "httpx not found in {:?}", result);
3584        let (_, symbols) = entry.unwrap();
3585        assert!(
3586            symbols.contains(&"Client".to_string()),
3587            "Client not in symbols: {:?}",
3588            symbols
3589        );
3590        assert!(
3591            symbols.contains(&"get".to_string()),
3592            "get not in symbols: {:?}",
3593            symbols
3594        );
3595    }
3596
3597    // -----------------------------------------------------------------------
3598    // PY-ATTR-03: `import httpx\nhttpx.Client()\nhttpx.Client()\n`
3599    //             -> specifier="httpx", symbols=["Client"] (deduplication)
3600    // -----------------------------------------------------------------------
3601    #[test]
3602    fn py_attr_03_bare_import_deduplicated_attributes() {
3603        // Given: source with a bare import and duplicate attribute accesses
3604        let source = "import httpx\nhttpx.Client()\nhttpx.Client()\n";
3605
3606        // When: extract_all_import_specifiers is called
3607        let extractor = PythonExtractor::new();
3608        let result = extractor.extract_all_import_specifiers(source);
3609
3610        // Then: contains ("httpx", ["Client"]) -- duplicates removed
3611        let entry = result.iter().find(|(spec, _)| spec == "httpx");
3612        assert!(entry.is_some(), "httpx not found in {:?}", result);
3613        let (_, symbols) = entry.unwrap();
3614        assert_eq!(
3615            symbols,
3616            &vec!["Client".to_string()],
3617            "expected [\"Client\"] with deduplication, got {:?}",
3618            symbols
3619        );
3620    }
3621
3622    // -----------------------------------------------------------------------
3623    // PY-ATTR-04: `import httpx\n` (no attribute access)
3624    //             -> specifier="httpx", symbols=[] (fallback: match all)
3625    //
3626    // NOTE: This test covers the same input as PY-IMPORT-01 but explicitly
3627    //       verifies the "no attribute access → symbols=[] fallback" contract
3628    //       introduced in Phase 16. PY-IMPORT-01 verifies the pre-Phase 16
3629    //       baseline; this test documents the Phase 16 intentional behaviour.
3630    // -----------------------------------------------------------------------
3631    #[test]
3632    fn py_attr_04_bare_import_no_attribute_fallback() {
3633        // Given: source with a bare import but no attribute access
3634        let source = "import httpx\n";
3635
3636        // When: extract_all_import_specifiers is called
3637        let extractor = PythonExtractor::new();
3638        let result = extractor.extract_all_import_specifiers(source);
3639
3640        // Then: contains ("httpx", []) -- no attribute access means match-all fallback
3641        let entry = result.iter().find(|(spec, _)| spec == "httpx");
3642        assert!(
3643            entry.is_some(),
3644            "httpx not found in {:?}; bare import without attribute access should be included",
3645            result
3646        );
3647        let (_, symbols) = entry.unwrap();
3648        assert!(
3649            symbols.is_empty(),
3650            "expected empty symbols (fallback) for bare import with no attribute access, got {:?}",
3651            symbols
3652        );
3653    }
3654
3655    // -----------------------------------------------------------------------
3656    // PY-ATTR-05: `from httpx import Client\n`
3657    //             -> specifier="httpx", symbols=["Client"]
3658    //             (regression: Phase 16 changes must not affect from-import)
3659    //
3660    // NOTE: This is a regression test verifying that Phase 16 attribute-access
3661    //       filtering does not change the behaviour of `from X import Y` paths.
3662    //       PY-IMPORT-03 tests the same input as a baseline; this test
3663    //       explicitly documents the Phase 16 non-regression requirement.
3664    // -----------------------------------------------------------------------
3665    #[test]
3666    fn py_attr_05_from_import_regression() {
3667        // Given: source with a from-import (must not be affected by Phase 16 changes)
3668        let source = "from httpx import Client\n";
3669
3670        // When: extract_all_import_specifiers is called
3671        let extractor = PythonExtractor::new();
3672        let result = extractor.extract_all_import_specifiers(source);
3673
3674        // Then: contains ("httpx", ["Client"]) -- from-import path unchanged
3675        let entry = result.iter().find(|(spec, _)| spec == "httpx");
3676        assert!(entry.is_some(), "httpx not found in {:?}", result);
3677        let (_, symbols) = entry.unwrap();
3678        assert!(
3679            symbols.contains(&"Client".to_string()),
3680            "Client not in symbols: {:?}",
3681            symbols
3682        );
3683    }
3684
3685    // -----------------------------------------------------------------------
3686    // PY-ATTR-06: e2e: `import pkg\npkg.Foo()\n`, barrel `from .mod import Foo`
3687    //             and `from .bar import Bar` -> mod.py mapped, bar.py NOT mapped
3688    //             (attribute-access filtering narrows barrel resolution)
3689    // -----------------------------------------------------------------------
3690    #[test]
3691    fn py_attr_06_e2e_attribute_access_narrows_barrel_mapping() {
3692        use tempfile::TempDir;
3693
3694        // Given: tempdir with:
3695        //   pkg/__init__.py: re-exports Foo from .mod and Bar from .bar
3696        //   pkg/mod.py: defines Foo
3697        //   pkg/bar.py: defines Bar
3698        //   tests/test_foo.py: uses bare `import pkg` and accesses only `pkg.Foo()`
3699        let dir = TempDir::new().unwrap();
3700        let pkg = dir.path().join("pkg");
3701        std::fs::create_dir_all(&pkg).unwrap();
3702
3703        std::fs::write(
3704            pkg.join("__init__.py"),
3705            "from .mod import Foo\nfrom .bar import Bar\n",
3706        )
3707        .unwrap();
3708        std::fs::write(pkg.join("mod.py"), "def Foo(): pass\n").unwrap();
3709        std::fs::write(pkg.join("bar.py"), "def Bar(): pass\n").unwrap();
3710
3711        let tests_dir = dir.path().join("tests");
3712        std::fs::create_dir_all(&tests_dir).unwrap();
3713        // Test file only accesses pkg.Foo, not pkg.Bar
3714        let test_content = "import pkg\npkg.Foo()\n";
3715        std::fs::write(tests_dir.join("test_foo.py"), test_content).unwrap();
3716
3717        let mod_path = pkg.join("mod.py").to_string_lossy().into_owned();
3718        let bar_path = pkg.join("bar.py").to_string_lossy().into_owned();
3719        let test_path = tests_dir.join("test_foo.py").to_string_lossy().into_owned();
3720
3721        let extractor = PythonExtractor::new();
3722        let production_files = vec![mod_path.clone(), bar_path.clone()];
3723        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3724            .into_iter()
3725            .collect();
3726
3727        // When: map_test_files_with_imports is called
3728        let result =
3729            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3730
3731        // Then: mod.py is mapped (Foo is accessed via pkg.Foo())
3732        let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
3733        assert!(
3734            mod_mapping.is_some(),
3735            "mod.py not mapped; pkg.Foo() should resolve to mod.py via barrel. mappings={:?}",
3736            result
3737        );
3738        assert!(
3739            mod_mapping.unwrap().test_files.contains(&test_path),
3740            "test_foo.py not in test_files for mod.py: {:?}",
3741            mod_mapping.unwrap().test_files
3742        );
3743
3744        // Then: bar.py is NOT mapped (Bar is not accessed -- pkg.Bar() is absent)
3745        let bar_mapping = result.iter().find(|m| m.production_file == bar_path);
3746        let bar_not_mapped = bar_mapping
3747            .map(|m| !m.test_files.contains(&test_path))
3748            .unwrap_or(true);
3749        assert!(
3750            bar_not_mapped,
3751            "bar.py should NOT be mapped for test_foo.py (pkg.Bar() is not accessed), but got: {:?}",
3752            bar_mapping
3753        );
3754    }
3755
3756    // -----------------------------------------------------------------------
3757    // PY-L1X-01: stem-only fallback: tests/test_client.py -> pkg/_client.py (cross-directory)
3758    //
3759    // The key scenario: test file is in tests/ but prod is in pkg/.
3760    // L1 core uses (dir, stem) pair, so tests/test_client.py (dir=tests/) does NOT
3761    // match pkg/_client.py (dir=pkg/) via L1 core.
3762    // stem-only fallback should match them via stem "client" regardless of directory.
3763    // The test file has NO import statements to avoid L2 from resolving the mapping.
3764    // -----------------------------------------------------------------------
3765    #[test]
3766    fn py_l1x_01_stem_only_fallback_cross_directory() {
3767        use tempfile::TempDir;
3768
3769        // Given: pkg/_client.py (prod) and tests/test_client.py (test, NO imports)
3770        //        L1 core cannot match (different dirs: pkg/ vs tests/)
3771        //        L2 cannot match (no import statements)
3772        //        stem-only fallback should match via stem "client"
3773        let dir = TempDir::new().unwrap();
3774        let pkg = dir.path().join("pkg");
3775        std::fs::create_dir_all(&pkg).unwrap();
3776        let tests_dir = dir.path().join("tests");
3777        std::fs::create_dir_all(&tests_dir).unwrap();
3778
3779        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
3780
3781        // No imports -- forces reliance on stem-only fallback (not L2)
3782        let test_content = "def test_client():\n    pass\n";
3783        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
3784
3785        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
3786        let test_path = tests_dir
3787            .join("test_client.py")
3788            .to_string_lossy()
3789            .into_owned();
3790
3791        let extractor = PythonExtractor::new();
3792        let production_files = vec![client_path.clone()];
3793        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3794            .into_iter()
3795            .collect();
3796
3797        // When: map_test_files_with_imports is called
3798        let result =
3799            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3800
3801        // Then: test_client.py is mapped to pkg/_client.py via stem-only fallback
3802        let mapping = result.iter().find(|m| m.production_file == client_path);
3803        assert!(
3804            mapping.is_some(),
3805            "pkg/_client.py not mapped; stem-only fallback should match across directories. mappings={:?}",
3806            result
3807        );
3808        let mapping = mapping.unwrap();
3809        assert!(
3810            mapping.test_files.contains(&test_path),
3811            "test_client.py not in test_files for pkg/_client.py: {:?}",
3812            mapping.test_files
3813        );
3814    }
3815
3816    // -----------------------------------------------------------------------
3817    // PY-L1X-02: stem-only: tests/test_decoders.py -> pkg/_decoders.py (_ prefix prod)
3818    //
3819    // production_stem strips leading _ so "_decoders" -> "decoders".
3820    // test_stem strips "test_" prefix so "test_decoders" -> "decoders".
3821    // stem-only fallback should match them even though dirs differ.
3822    // -----------------------------------------------------------------------
3823    #[test]
3824    fn py_l1x_02_stem_only_underscore_prefix_prod() {
3825        use tempfile::TempDir;
3826
3827        // Given: pkg/_decoders.py and tests/test_decoders.py (no imports)
3828        let dir = TempDir::new().unwrap();
3829        let pkg = dir.path().join("pkg");
3830        std::fs::create_dir_all(&pkg).unwrap();
3831        let tests_dir = dir.path().join("tests");
3832        std::fs::create_dir_all(&tests_dir).unwrap();
3833
3834        std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
3835
3836        // No imports -- forces reliance on stem-only fallback
3837        let test_content = "def test_decode():\n    pass\n";
3838        std::fs::write(tests_dir.join("test_decoders.py"), test_content).unwrap();
3839
3840        let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
3841        let test_path = tests_dir
3842            .join("test_decoders.py")
3843            .to_string_lossy()
3844            .into_owned();
3845
3846        let extractor = PythonExtractor::new();
3847        let production_files = vec![decoders_path.clone()];
3848        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3849            .into_iter()
3850            .collect();
3851
3852        // When: map_test_files_with_imports is called
3853        let result =
3854            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3855
3856        // Then: test_decoders.py is mapped to pkg/_decoders.py via stem-only fallback
3857        //       (production_stem strips '_' prefix: "_decoders" -> "decoders")
3858        let mapping = result.iter().find(|m| m.production_file == decoders_path);
3859        assert!(
3860            mapping.is_some(),
3861            "pkg/_decoders.py not mapped; stem-only fallback should strip _ prefix and match. mappings={:?}",
3862            result
3863        );
3864        let mapping = mapping.unwrap();
3865        assert!(
3866            mapping.test_files.contains(&test_path),
3867            "test_decoders.py not in test_files for pkg/_decoders.py: {:?}",
3868            mapping.test_files
3869        );
3870    }
3871
3872    // -----------------------------------------------------------------------
3873    // PY-L1X-03: stem-only: tests/test_asgi.py -> pkg/transports/asgi.py (subdirectory)
3874    //
3875    // Prod is in a subdirectory (pkg/transports/), test is in tests/.
3876    // stem "asgi" should match across any directory depth.
3877    // -----------------------------------------------------------------------
3878    #[test]
3879    fn py_l1x_03_stem_only_subdirectory_prod() {
3880        use tempfile::TempDir;
3881
3882        // Given: pkg/transports/asgi.py and tests/test_asgi.py (no imports)
3883        let dir = TempDir::new().unwrap();
3884        let transports = dir.path().join("pkg").join("transports");
3885        std::fs::create_dir_all(&transports).unwrap();
3886        let tests_dir = dir.path().join("tests");
3887        std::fs::create_dir_all(&tests_dir).unwrap();
3888
3889        std::fs::write(
3890            transports.join("asgi.py"),
3891            "class ASGITransport:\n    pass\n",
3892        )
3893        .unwrap();
3894
3895        // No imports -- forces reliance on stem-only fallback
3896        let test_content = "def test_asgi_transport():\n    pass\n";
3897        std::fs::write(tests_dir.join("test_asgi.py"), test_content).unwrap();
3898
3899        let asgi_path = transports.join("asgi.py").to_string_lossy().into_owned();
3900        let test_path = tests_dir
3901            .join("test_asgi.py")
3902            .to_string_lossy()
3903            .into_owned();
3904
3905        let extractor = PythonExtractor::new();
3906        let production_files = vec![asgi_path.clone()];
3907        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3908            .into_iter()
3909            .collect();
3910
3911        // When: map_test_files_with_imports is called
3912        let result =
3913            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3914
3915        // Then: test_asgi.py is mapped to pkg/transports/asgi.py
3916        //       (stem "asgi" matches across directory depth)
3917        let mapping = result.iter().find(|m| m.production_file == asgi_path);
3918        assert!(
3919            mapping.is_some(),
3920            "pkg/transports/asgi.py not mapped; stem 'asgi' should match across directory depth. mappings={:?}",
3921            result
3922        );
3923        let mapping = mapping.unwrap();
3924        assert!(
3925            mapping.test_files.contains(&test_path),
3926            "test_asgi.py not in test_files for pkg/transports/asgi.py: {:?}",
3927            mapping.test_files
3928        );
3929    }
3930
3931    // -----------------------------------------------------------------------
3932    // PY-L1X-04: stem ambiguity: same stem in multiple prod files -> mapped to all
3933    //
3934    // When multiple prod files share the same stem, recall takes priority:
3935    // all matching prod files should include the test.
3936    // -----------------------------------------------------------------------
3937    #[test]
3938    fn py_l1x_04_stem_ambiguity_maps_to_all() {
3939        use tempfile::TempDir;
3940
3941        // Given: pkg/client.py, pkg/aio/client.py, and tests/test_client.py (no imports)
3942        //        Both have stem "client"; test has stem "client" -> should map to both
3943        let dir = TempDir::new().unwrap();
3944        let pkg = dir.path().join("pkg");
3945        let pkg_aio = pkg.join("aio");
3946        std::fs::create_dir_all(&pkg).unwrap();
3947        std::fs::create_dir_all(&pkg_aio).unwrap();
3948        let tests_dir = dir.path().join("tests");
3949        std::fs::create_dir_all(&tests_dir).unwrap();
3950
3951        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
3952        std::fs::write(pkg_aio.join("client.py"), "class AsyncClient:\n    pass\n").unwrap();
3953
3954        // No imports -- forces reliance on stem-only fallback
3955        let test_content = "def test_client():\n    pass\n";
3956        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
3957
3958        let client_path = pkg.join("client.py").to_string_lossy().into_owned();
3959        let aio_client_path = pkg_aio.join("client.py").to_string_lossy().into_owned();
3960        let test_path = tests_dir
3961            .join("test_client.py")
3962            .to_string_lossy()
3963            .into_owned();
3964
3965        let extractor = PythonExtractor::new();
3966        let production_files = vec![client_path.clone(), aio_client_path.clone()];
3967        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
3968            .into_iter()
3969            .collect();
3970
3971        // When: map_test_files_with_imports is called
3972        let result =
3973            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3974
3975        // Then: test_client.py is mapped to BOTH pkg/client.py and pkg/aio/client.py
3976        //       (recall priority: all matching stems are included)
3977        let client_mapped = result
3978            .iter()
3979            .find(|m| m.production_file == client_path)
3980            .map(|m| m.test_files.contains(&test_path))
3981            .unwrap_or(false);
3982        assert!(
3983            client_mapped,
3984            "test_client.py should be mapped to pkg/client.py (stem ambiguity -> all). mappings={:?}",
3985            result
3986        );
3987
3988        let aio_mapped = result
3989            .iter()
3990            .find(|m| m.production_file == aio_client_path)
3991            .map(|m| m.test_files.contains(&test_path))
3992            .unwrap_or(false);
3993        assert!(
3994            aio_mapped,
3995            "test_client.py should be mapped to pkg/aio/client.py (stem ambiguity -> all). mappings={:?}",
3996            result
3997        );
3998    }
3999
4000    // -----------------------------------------------------------------------
4001    // PY-L1X-05: L1 core match already found -> stem-only fallback does NOT fire
4002    //
4003    // When L1 core (dir, stem) already matches, stem-only fallback should be
4004    // suppressed for that test to avoid adding cross-directory duplicates.
4005    // Note: production file uses svc/ (not tests/) since Phase 20 excludes tests/ files.
4006    // -----------------------------------------------------------------------
4007    #[test]
4008    fn py_l1x_05_l1_core_match_suppresses_fallback() {
4009        use tempfile::TempDir;
4010
4011        // Given: svc/client.py (L1 core match: dir=svc/, stem=client)
4012        //        pkg/client.py (would match via stem-only fallback if L1 core is absent)
4013        //        svc/test_client.py (no imports)
4014        let dir = TempDir::new().unwrap();
4015        let pkg = dir.path().join("pkg");
4016        let svc = dir.path().join("svc");
4017        std::fs::create_dir_all(&pkg).unwrap();
4018        std::fs::create_dir_all(&svc).unwrap();
4019
4020        std::fs::write(svc.join("client.py"), "class Client:\n    pass\n").unwrap();
4021        std::fs::write(pkg.join("client.py"), "class Client:\n    pass\n").unwrap();
4022
4023        // No imports -- avoids L2 influence; only L1 core and stem-only fallback apply
4024        let test_content = "def test_client():\n    pass\n";
4025        std::fs::write(svc.join("test_client.py"), test_content).unwrap();
4026
4027        let svc_client_path = svc.join("client.py").to_string_lossy().into_owned();
4028        let pkg_client_path = pkg.join("client.py").to_string_lossy().into_owned();
4029        let test_path = svc.join("test_client.py").to_string_lossy().into_owned();
4030
4031        let extractor = PythonExtractor::new();
4032        let production_files = vec![svc_client_path.clone(), pkg_client_path.clone()];
4033        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4034            .into_iter()
4035            .collect();
4036
4037        // When: map_test_files_with_imports is called
4038        let result =
4039            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4040
4041        // Then: test_client.py is mapped to svc/client.py only (L1 core match)
4042        let svc_client_mapped = result
4043            .iter()
4044            .find(|m| m.production_file == svc_client_path)
4045            .map(|m| m.test_files.contains(&test_path))
4046            .unwrap_or(false);
4047        assert!(
4048            svc_client_mapped,
4049            "test_client.py should be mapped to svc/client.py via L1 core. mappings={:?}",
4050            result
4051        );
4052
4053        // Then: fallback does NOT add pkg/client.py (L1 core match suppresses fallback)
4054        let pkg_not_mapped = result
4055            .iter()
4056            .find(|m| m.production_file == pkg_client_path)
4057            .map(|m| !m.test_files.contains(&test_path))
4058            .unwrap_or(true);
4059        assert!(
4060            pkg_not_mapped,
4061            "pkg/client.py should NOT be mapped (L1 core match suppresses stem-only fallback). mappings={:?}",
4062            result
4063        );
4064    }
4065
4066    // -----------------------------------------------------------------------
4067    // PY-SUP-01: barrel suppression: L1 stem-only matched test does not get barrel fan-out
4068    //
4069    // The httpx FP scenario:
4070    // - tests/test_client.py has NO specific imports (bare `import pkg` + no attribute access)
4071    // - Without barrel suppression: `import pkg` -> barrel -> _client.py + _utils.py (FP!)
4072    // - With stem-only L1 match + barrel suppression:
4073    //   test_client.py -> L1 stem-only -> _client.py only (barrel _utils.py suppressed)
4074    // -----------------------------------------------------------------------
4075    #[test]
4076    fn py_sup_01_barrel_suppression_l1_matched_no_barrel_fan_out() {
4077        use tempfile::TempDir;
4078
4079        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
4080        //        tests/test_client.py: `import pkg` (bare import, NO attribute access)
4081        //        L1 stem-only fallback: test_client.py -> pkg/_client.py (stem "client")
4082        let dir = TempDir::new().unwrap();
4083        let pkg = dir.path().join("pkg");
4084        std::fs::create_dir_all(&pkg).unwrap();
4085        let tests_dir = dir.path().join("tests");
4086        std::fs::create_dir_all(&tests_dir).unwrap();
4087
4088        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4089        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4090        std::fs::write(
4091            pkg.join("__init__.py"),
4092            "from ._client import Client\nfrom ._utils import format_url\n",
4093        )
4094        .unwrap();
4095
4096        // bare `import pkg` with NO attribute access -> symbols=[] -> barrel fan-out to all
4097        // Without barrel suppression: _client.py AND _utils.py both mapped (FP for _utils)
4098        // With barrel suppression (L1 matched): only _client.py mapped
4099        let test_content = "import pkg\n\ndef test_client():\n    pass\n";
4100        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4101
4102        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4103        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4104        let test_path = tests_dir
4105            .join("test_client.py")
4106            .to_string_lossy()
4107            .into_owned();
4108
4109        let extractor = PythonExtractor::new();
4110        let production_files = vec![client_path.clone(), utils_path.clone()];
4111        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4112            .into_iter()
4113            .collect();
4114
4115        // When: map_test_files_with_imports is called
4116        let result =
4117            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4118
4119        // Then: _client.py IS mapped (L1 stem-only match)
4120        let client_mapped = result
4121            .iter()
4122            .find(|m| m.production_file == client_path)
4123            .map(|m| m.test_files.contains(&test_path))
4124            .unwrap_or(false);
4125        assert!(
4126            client_mapped,
4127            "pkg/_client.py should be mapped via L1 stem-only. mappings={:?}",
4128            result
4129        );
4130
4131        // Then: _utils.py is NOT mapped (barrel fan-out suppressed because L1 stem-only matched)
4132        let utils_not_mapped = result
4133            .iter()
4134            .find(|m| m.production_file == utils_path)
4135            .map(|m| !m.test_files.contains(&test_path))
4136            .unwrap_or(true);
4137        assert!(
4138            utils_not_mapped,
4139            "pkg/_utils.py should NOT be mapped (barrel suppression for L1-matched test_client.py). mappings={:?}",
4140            result
4141        );
4142    }
4143
4144    // -----------------------------------------------------------------------
4145    // PY-SUP-02: barrel suppression: L1 stem-only matched test still gets direct imports
4146    //
4147    // Direct imports (from pkg._utils import format_url) bypass barrel resolution.
4148    // Even if L1 stem-only matches _client.py, direct imports to _utils.py are added.
4149    // -----------------------------------------------------------------------
4150    #[test]
4151    fn py_sup_02_barrel_suppression_direct_import_still_added() {
4152        use tempfile::TempDir;
4153
4154        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
4155        //        tests/test_client.py:
4156        //          - `import pkg` (bare import, no attribute access -> would fan-out to barrel)
4157        //          - `from pkg._utils import format_url` (direct import)
4158        //        L1 stem-only: test_client.py -> _client.py
4159        let dir = TempDir::new().unwrap();
4160        let pkg = dir.path().join("pkg");
4161        std::fs::create_dir_all(&pkg).unwrap();
4162        let tests_dir = dir.path().join("tests");
4163        std::fs::create_dir_all(&tests_dir).unwrap();
4164
4165        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4166        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4167        std::fs::write(
4168            pkg.join("__init__.py"),
4169            "from ._client import Client\nfrom ._utils import format_url\n",
4170        )
4171        .unwrap();
4172
4173        // Direct import to _utils -- this is NOT via barrel, so suppression does not apply
4174        let test_content =
4175            "import pkg\nfrom pkg._utils import format_url\n\ndef test_client():\n    assert format_url('http://x')\n";
4176        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
4177
4178        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4179        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4180        let test_path = tests_dir
4181            .join("test_client.py")
4182            .to_string_lossy()
4183            .into_owned();
4184
4185        let extractor = PythonExtractor::new();
4186        let production_files = vec![client_path.clone(), utils_path.clone()];
4187        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4188            .into_iter()
4189            .collect();
4190
4191        // When: map_test_files_with_imports is called
4192        let result =
4193            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4194
4195        // Then: _utils.py IS mapped (direct import bypasses barrel suppression)
4196        let utils_mapped = result
4197            .iter()
4198            .find(|m| m.production_file == utils_path)
4199            .map(|m| m.test_files.contains(&test_path))
4200            .unwrap_or(false);
4201        assert!(
4202            utils_mapped,
4203            "pkg/_utils.py should be mapped via direct import (not barrel). mappings={:?}",
4204            result
4205        );
4206    }
4207
4208    // -----------------------------------------------------------------------
4209    // PY-SUP-03: barrel suppression: L1-unmatched test gets barrel fan-out as usual
4210    //
4211    // A test with NO matching stem in prod files should still get barrel fan-out.
4212    // Barrel suppression only applies to L1 stem-only matched tests.
4213    // -----------------------------------------------------------------------
4214    #[test]
4215    fn py_sup_03_barrel_suppression_l1_unmatched_gets_barrel() {
4216        use tempfile::TempDir;
4217
4218        // Given: pkg/_client.py, pkg/_utils.py, pkg/__init__.py (barrel)
4219        //        tests/test_exported_members.py: `import pkg` (bare import, no attr access)
4220        //        stem "exported_members" has NO matching production file (L1 miss)
4221        let dir = TempDir::new().unwrap();
4222        let pkg = dir.path().join("pkg");
4223        std::fs::create_dir_all(&pkg).unwrap();
4224        let tests_dir = dir.path().join("tests");
4225        std::fs::create_dir_all(&tests_dir).unwrap();
4226
4227        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4228        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4229        std::fs::write(
4230            pkg.join("__init__.py"),
4231            "from ._client import Client\nfrom ._utils import format_url\n",
4232        )
4233        .unwrap();
4234
4235        // bare `import pkg` with NO attribute access -> should fan-out via barrel (L1 miss)
4236        let test_content = "import pkg\n\ndef test_exported_members():\n    pass\n";
4237        std::fs::write(tests_dir.join("test_exported_members.py"), test_content).unwrap();
4238
4239        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4240        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4241        let test_path = tests_dir
4242            .join("test_exported_members.py")
4243            .to_string_lossy()
4244            .into_owned();
4245
4246        let extractor = PythonExtractor::new();
4247        let production_files = vec![client_path.clone(), utils_path.clone()];
4248        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4249            .into_iter()
4250            .collect();
4251
4252        // When: map_test_files_with_imports is called
4253        let result =
4254            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4255
4256        // Then: barrel fan-out proceeds for L1-unmatched test
4257        //       BOTH _client.py and _utils.py should be mapped (barrel re-exports both)
4258        let client_mapped = result
4259            .iter()
4260            .find(|m| m.production_file == client_path)
4261            .map(|m| m.test_files.contains(&test_path))
4262            .unwrap_or(false);
4263        let utils_mapped = result
4264            .iter()
4265            .find(|m| m.production_file == utils_path)
4266            .map(|m| m.test_files.contains(&test_path))
4267            .unwrap_or(false);
4268
4269        assert!(
4270            client_mapped && utils_mapped,
4271            "L1-unmatched test should fan-out via barrel to both _client.py and _utils.py. client_mapped={}, utils_mapped={}, mappings={:?}",
4272            client_mapped,
4273            utils_mapped,
4274            result
4275        );
4276    }
4277
4278    // -----------------------------------------------------------------------
4279    // PY-SUP-04: E2E: httpx-like fixture demonstrates FP reduction (P >= 80%)
4280    //
4281    // Simulates the core httpx FP scenario:
4282    // - Multiple prod files under pkg/ with underscore prefix
4283    // - tests/ directory (different from pkg/)
4284    // - Some tests import pkg bare (no attribute access) -> currently fans-out to all
4285    // - stem-only fallback + barrel suppression should limit fan-out
4286    //
4287    // Note: P>=80% is the intermediate goal; Ship criteria is P>=98% (CONSTITUTION).
4288    // -----------------------------------------------------------------------
4289    #[test]
4290    fn py_sup_04_e2e_httpx_like_precision_improvement() {
4291        use tempfile::TempDir;
4292        use HashSet;
4293
4294        // Given: httpx-like structure
4295        //   pkg/_client.py, pkg/_decoders.py, pkg/_utils.py
4296        //   pkg/__init__.py: barrel re-exporting Client, decode, format_url
4297        //   tests/test_client.py: bare `import pkg` NO attribute access (stem -> _client.py)
4298        //   tests/test_decoders.py: bare `import pkg` NO attribute access (stem -> _decoders.py)
4299        //   tests/test_utils.py: bare `import pkg` NO attribute access (stem -> _utils.py)
4300        //   tests/test_exported_members.py: bare `import pkg` NO attr access (L1 miss -> barrel OK)
4301        let dir = TempDir::new().unwrap();
4302        let pkg = dir.path().join("pkg");
4303        std::fs::create_dir_all(&pkg).unwrap();
4304        let tests_dir = dir.path().join("tests");
4305        std::fs::create_dir_all(&tests_dir).unwrap();
4306
4307        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4308        std::fs::write(pkg.join("_decoders.py"), "def decode(x): return x\n").unwrap();
4309        std::fs::write(pkg.join("_utils.py"), "def format_url(u): return u\n").unwrap();
4310        std::fs::write(
4311            pkg.join("__init__.py"),
4312            "from ._client import Client\nfrom ._decoders import decode\nfrom ._utils import format_url\n",
4313        )
4314        .unwrap();
4315
4316        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4317        let decoders_path = pkg.join("_decoders.py").to_string_lossy().into_owned();
4318        let utils_path = pkg.join("_utils.py").to_string_lossy().into_owned();
4319        let production_files = vec![
4320            client_path.clone(),
4321            decoders_path.clone(),
4322            utils_path.clone(),
4323        ];
4324
4325        // All test files use bare `import pkg` with NO attribute access
4326        // -> without suppression: all fan-out to all 3 prod files (P=33%)
4327        // -> with stem-only + barrel suppression: each maps to 1 (P=100% for L1-matched)
4328        let test_client_content = "import pkg\n\ndef test_client():\n    pass\n";
4329        let test_decoders_content = "import pkg\n\ndef test_decode():\n    pass\n";
4330        let test_utils_content = "import pkg\n\ndef test_format_url():\n    pass\n";
4331        let test_exported_content = "import pkg\n\ndef test_exported_members():\n    pass\n";
4332
4333        let test_client_path = tests_dir
4334            .join("test_client.py")
4335            .to_string_lossy()
4336            .into_owned();
4337        let test_decoders_path = tests_dir
4338            .join("test_decoders.py")
4339            .to_string_lossy()
4340            .into_owned();
4341        let test_utils_path = tests_dir
4342            .join("test_utils.py")
4343            .to_string_lossy()
4344            .into_owned();
4345        let test_exported_path = tests_dir
4346            .join("test_exported_members.py")
4347            .to_string_lossy()
4348            .into_owned();
4349
4350        std::fs::write(&test_client_path, test_client_content).unwrap();
4351        std::fs::write(&test_decoders_path, test_decoders_content).unwrap();
4352        std::fs::write(&test_utils_path, test_utils_content).unwrap();
4353        std::fs::write(&test_exported_path, test_exported_content).unwrap();
4354
4355        let test_sources: HashMap<String, String> = [
4356            (test_client_path.clone(), test_client_content.to_string()),
4357            (
4358                test_decoders_path.clone(),
4359                test_decoders_content.to_string(),
4360            ),
4361            (test_utils_path.clone(), test_utils_content.to_string()),
4362            (
4363                test_exported_path.clone(),
4364                test_exported_content.to_string(),
4365            ),
4366        ]
4367        .into_iter()
4368        .collect();
4369
4370        let extractor = PythonExtractor::new();
4371
4372        // When: map_test_files_with_imports is called
4373        let result =
4374            extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
4375
4376        // Ground truth (expected TP pairs):
4377        // test_client.py -> _client.py  (L1 stem-only)
4378        // test_decoders.py -> _decoders.py  (L1 stem-only)
4379        // test_utils.py -> _utils.py  (L1 stem-only)
4380        // test_exported_members.py -> _client.py, _decoders.py, _utils.py  (barrel, L1 miss)
4381        let ground_truth_set: HashSet<(String, String)> = [
4382            (test_client_path.clone(), client_path.clone()),
4383            (test_decoders_path.clone(), decoders_path.clone()),
4384            (test_utils_path.clone(), utils_path.clone()),
4385            (test_exported_path.clone(), client_path.clone()),
4386            (test_exported_path.clone(), decoders_path.clone()),
4387            (test_exported_path.clone(), utils_path.clone()),
4388        ]
4389        .into_iter()
4390        .collect();
4391
4392        let actual_pairs: HashSet<(String, String)> = result
4393            .iter()
4394            .flat_map(|m| {
4395                m.test_files
4396                    .iter()
4397                    .map(|t| (t.clone(), m.production_file.clone()))
4398                    .collect::<Vec<_>>()
4399            })
4400            .collect();
4401
4402        let tp = actual_pairs.intersection(&ground_truth_set).count();
4403        let fp = actual_pairs.difference(&ground_truth_set).count();
4404
4405        // Precision = TP / (TP + FP)
4406        let precision = if tp + fp == 0 {
4407            0.0
4408        } else {
4409            tp as f64 / (tp + fp) as f64
4410        };
4411
4412        // Then: precision >= 80% (intermediate goal)
4413        // Without stem-only + barrel suppression: all 4 tests fan-out to 3 prod files
4414        // = 12 pairs, but GT has 6 -> P = 6/12 = 50% (FAIL)
4415        // With suppression: 3 stem-matched tests -> 1 each + exported_members -> 3 = 6 pairs -> P = 100%
4416        assert!(
4417            precision >= 0.80,
4418            "Precision {:.1}% < 80% target. TP={}, FP={}, actual_pairs={:?}",
4419            precision * 100.0,
4420            tp,
4421            fp,
4422            actual_pairs
4423        );
4424    }
4425
4426    // -----------------------------------------------------------------------
4427    // PY-AF-01: assignment + assertion tracking
4428    //   `client = Client(); assert client.ok` -> Client in asserted_imports
4429    // -----------------------------------------------------------------------
4430    #[test]
4431    fn py_af_01_assert_via_assigned_var() {
4432        // Given: source where Client is assigned then asserted
4433        let source = r#"
4434from pkg.client import Client
4435
4436def test_something():
4437    client = Client()
4438    assert client.ok
4439"#;
4440        // When: extract_assertion_referenced_imports is called
4441        let result = extract_assertion_referenced_imports(source);
4442
4443        // Then: Client is in asserted_imports (assigned var `client` appears in assertion)
4444        assert!(
4445            result.contains("Client"),
4446            "Client should be in asserted_imports; got {:?}",
4447            result
4448        );
4449    }
4450
4451    // -----------------------------------------------------------------------
4452    // PY-AF-02: non-asserted assignment is excluded
4453    //   `transport = MockTransport()` (not in assert) -> MockTransport NOT in asserted_imports
4454    // -----------------------------------------------------------------------
4455    #[test]
4456    fn py_af_02_setup_only_import_excluded() {
4457        // Given: source where MockTransport is assigned but never asserted
4458        let source = r#"
4459from pkg.client import Client
4460from pkg.transport import MockTransport
4461
4462def test_something():
4463    transport = MockTransport()
4464    client = Client(transport=transport)
4465    assert client.ok
4466"#;
4467        // When: extract_assertion_referenced_imports is called
4468        let result = extract_assertion_referenced_imports(source);
4469
4470        // Then: MockTransport is NOT in asserted_imports (only used in setup)
4471        assert!(
4472            !result.contains("MockTransport"),
4473            "MockTransport should NOT be in asserted_imports (setup-only); got {:?}",
4474            result
4475        );
4476        // And: Client IS in asserted_imports (via chain: client -> Client)
4477        assert!(
4478            result.contains("Client"),
4479            "Client should be in asserted_imports; got {:?}",
4480            result
4481        );
4482    }
4483
4484    // -----------------------------------------------------------------------
4485    // PY-AF-03: direct usage in assertion
4486    //   `assert A() == B()` -> both A, B in asserted_imports
4487    // -----------------------------------------------------------------------
4488    #[test]
4489    fn py_af_03_direct_call_in_assertion() {
4490        // Given: source where two classes are directly called inside an assert
4491        let source = r#"
4492from pkg.models import A, B
4493
4494def test_equality():
4495    assert A() == B()
4496"#;
4497        // When: extract_assertion_referenced_imports is called
4498        let result = extract_assertion_referenced_imports(source);
4499
4500        // Then: both A and B are in asserted_imports
4501        assert!(
4502            result.contains("A"),
4503            "A should be in asserted_imports (used directly in assert); got {:?}",
4504            result
4505        );
4506        assert!(
4507            result.contains("B"),
4508            "B should be in asserted_imports (used directly in assert); got {:?}",
4509            result
4510        );
4511    }
4512
4513    // -----------------------------------------------------------------------
4514    // PY-AF-04: pytest.raises context
4515    //   `pytest.raises(HTTPError)` -> HTTPError in asserted_imports
4516    // -----------------------------------------------------------------------
4517    #[test]
4518    fn py_af_04_pytest_raises_captures_exception_class() {
4519        // Given: source using pytest.raises with an imported exception class
4520        let source = r#"
4521import pytest
4522from pkg.exceptions import HTTPError
4523
4524def test_raises():
4525    with pytest.raises(HTTPError):
4526        raise HTTPError("fail")
4527"#;
4528        // When: extract_assertion_referenced_imports is called
4529        let result = extract_assertion_referenced_imports(source);
4530
4531        // Then: HTTPError is in asserted_imports (appears in pytest.raises assertion node)
4532        assert!(
4533            result.contains("HTTPError"),
4534            "HTTPError should be in asserted_imports (pytest.raises arg); got {:?}",
4535            result
4536        );
4537    }
4538
4539    // -----------------------------------------------------------------------
4540    // PY-AF-05: chain tracking (2-hop)
4541    //   `response = client.get(); assert response.ok` -> Client reachable via chain
4542    //   client -> Client (1-hop), response -> client (2-hop through method call source)
4543    // -----------------------------------------------------------------------
4544    #[test]
4545    fn py_af_05_chain_tracking_two_hops() {
4546        // Given: source with a 2-hop chain: response derived from client, client from Client()
4547        let source = r#"
4548from pkg.client import Client
4549
4550def test_response():
4551    client = Client()
4552    response = client.get("http://example.com/")
4553    assert response.ok
4554"#;
4555        // When: extract_assertion_referenced_imports is called
4556        let result = extract_assertion_referenced_imports(source);
4557
4558        // Then: Client is reachable (response -> client -> Client, 2-hop chain)
4559        assert!(
4560            result.contains("Client"),
4561            "Client should be in asserted_imports via 2-hop chain; got {:?}",
4562            result
4563        );
4564    }
4565
4566    // -----------------------------------------------------------------------
4567    // PY-AF-06a: no assertions -> empty asserted_imports -> fallback to all_matched
4568    //   When assertion.scm detects no assertions, asserted_imports is empty
4569    //   and the caller falls back to all_matched.
4570    // -----------------------------------------------------------------------
4571    #[test]
4572    fn py_af_06a_no_assertions_returns_empty() {
4573        // Given: source with imports but zero assertion statements
4574        let source = r#"
4575from pkg.client import Client
4576from pkg.transport import MockTransport
4577
4578def test_setup_no_assert():
4579    client = Client()
4580    transport = MockTransport()
4581    # No assert statement at all
4582"#;
4583        // When: extract_assertion_referenced_imports is called
4584        let result = extract_assertion_referenced_imports(source);
4585
4586        // Then: asserted_imports is EMPTY (no assertions found, so no symbols traced)
4587        // The caller (map_test_files_with_imports) is responsible for the fallback.
4588        assert!(
4589            result.is_empty(),
4590            "expected empty asserted_imports when no assertions present; got {:?}",
4591            result
4592        );
4593    }
4594
4595    // -----------------------------------------------------------------------
4596    // PY-AF-06b: assertions exist but no asserted import intersects with L2 imports
4597    //   -> asserted_imports non-empty but does not overlap with any import symbol
4598    //   -> fallback to all_matched (safe side)
4599    // -----------------------------------------------------------------------
4600    #[test]
4601    fn py_af_06b_assertion_exists_but_no_import_intersection() {
4602        // Given: source where the assertion references a local variable (not an import)
4603        let source = r#"
4604from pkg.client import Client
4605
4606def test_local_only():
4607    local_value = 42
4608    # Assertion references only a local literal, not any imported symbol
4609    assert local_value == 42
4610"#;
4611        // When: extract_assertion_referenced_imports is called
4612        let result = extract_assertion_referenced_imports(source);
4613
4614        // Then: asserted_imports does NOT contain Client
4615        // (Client is never referenced inside an assertion node)
4616        assert!(
4617            !result.contains("Client"),
4618            "Client should NOT be in asserted_imports (not referenced in assertion); got {:?}",
4619            result
4620        );
4621        // Note: `result` may be empty or contain other identifiers from the assertion,
4622        // but the key property is that the imported symbol Client is absent.
4623    }
4624
4625    // -----------------------------------------------------------------------
4626    // PY-AF-07: unittest self.assert* form
4627    //   `self.assertEqual(result.value, 42)` -> result's import captured
4628    //   result = MyModel() -> MyModel in asserted_imports
4629    // -----------------------------------------------------------------------
4630    #[test]
4631    fn py_af_07_unittest_self_assert() {
4632        // Given: unittest-style test using self.assertEqual
4633        let source = r#"
4634import unittest
4635from pkg.models import MyModel
4636
4637class TestMyModel(unittest.TestCase):
4638    def test_value(self):
4639        result = MyModel()
4640        self.assertEqual(result.value, 42)
4641"#;
4642        // When: extract_assertion_referenced_imports is called
4643        let result = extract_assertion_referenced_imports(source);
4644
4645        // Then: MyModel is in asserted_imports (result -> MyModel, result in assertEqual)
4646        assert!(
4647            result.contains("MyModel"),
4648            "MyModel should be in asserted_imports via self.assertEqual; got {:?}",
4649            result
4650        );
4651    }
4652
4653    // -----------------------------------------------------------------------
4654    // PY-AF-08: E2E integration — primary import kept, incidental filtered
4655    //
4656    // Fixture: af_pkg/
4657    //   pkg/client.py    (Client class)   <- should be mapped
4658    //   pkg/transport.py (MockTransport)  <- should NOT be mapped (assertion filter)
4659    //   tests/test_client.py imports both, asserts only client.is_ok
4660    // -----------------------------------------------------------------------
4661    #[test]
4662    fn py_af_08_e2e_primary_kept_incidental_filtered() {
4663        use std::path::PathBuf;
4664        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4665            .parent()
4666            .unwrap()
4667            .parent()
4668            .unwrap()
4669            .join("tests/fixtures/python/observe/af_pkg");
4670
4671        let test_file = fixture_root
4672            .join("tests/test_client.py")
4673            .to_string_lossy()
4674            .into_owned();
4675        let client_prod = fixture_root
4676            .join("pkg/client.py")
4677            .to_string_lossy()
4678            .into_owned();
4679        let transport_prod = fixture_root
4680            .join("pkg/transport.py")
4681            .to_string_lossy()
4682            .into_owned();
4683
4684        let production_files = vec![client_prod.clone(), transport_prod.clone()];
4685        let test_source =
4686            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4687        let mut test_sources = HashMap::new();
4688        test_sources.insert(test_file.clone(), test_source);
4689
4690        // When: map_test_files_with_imports is called
4691        let extractor = PythonExtractor::new();
4692        let result =
4693            extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4694
4695        // Then: test_client.py maps to client.py (Client is asserted)
4696        let client_mapping = result.iter().find(|m| m.production_file == client_prod);
4697        assert!(
4698            client_mapping.is_some(),
4699            "client.py should be in mappings; got {:?}",
4700            result
4701                .iter()
4702                .map(|m| &m.production_file)
4703                .collect::<Vec<_>>()
4704        );
4705        assert!(
4706            client_mapping.unwrap().test_files.contains(&test_file),
4707            "test_client.py should map to client.py"
4708        );
4709
4710        // And: test_client.py does NOT map to transport.py (MockTransport not asserted)
4711        let transport_mapping = result.iter().find(|m| m.production_file == transport_prod);
4712        let transport_maps_test = transport_mapping
4713            .map(|m| m.test_files.contains(&test_file))
4714            .unwrap_or(false);
4715        assert!(
4716            !transport_maps_test,
4717            "test_client.py should NOT map to transport.py (assertion filter); got {:?}",
4718            result
4719                .iter()
4720                .map(|m| (&m.production_file, &m.test_files))
4721                .collect::<Vec<_>>()
4722        );
4723    }
4724
4725    // -----------------------------------------------------------------------
4726    // PY-AF-09: E2E — ALL imports incidental -> fallback, no regression (FN prevented)
4727    //
4728    // Fixture: af_e2e_fallback/
4729    //   pkg/helpers.py (HelperA, HelperB)
4730    //   tests/test_helpers.py: imports both, assertion is about `result is None`
4731    //   -> asserted_matched would be empty -> fallback to all_matched
4732    //   -> helpers.py MUST appear in the mapping (no FN)
4733    // -----------------------------------------------------------------------
4734    #[test]
4735    fn py_af_09_e2e_all_incidental_fallback_no_fn() {
4736        use std::path::PathBuf;
4737        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4738            .parent()
4739            .unwrap()
4740            .parent()
4741            .unwrap()
4742            .join("tests/fixtures/python/observe/af_e2e_fallback");
4743
4744        let test_file = fixture_root
4745            .join("tests/test_helpers.py")
4746            .to_string_lossy()
4747            .into_owned();
4748        let helpers_prod = fixture_root
4749            .join("pkg/helpers.py")
4750            .to_string_lossy()
4751            .into_owned();
4752
4753        let production_files = vec![helpers_prod.clone()];
4754        let test_source =
4755            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4756        let mut test_sources = HashMap::new();
4757        test_sources.insert(test_file.clone(), test_source);
4758
4759        // When: map_test_files_with_imports is called
4760        let extractor = PythonExtractor::new();
4761        let result =
4762            extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4763
4764        // Then: helpers.py IS mapped (fallback activated because asserted_matched is empty)
4765        let helpers_mapping = result.iter().find(|m| m.production_file == helpers_prod);
4766        assert!(
4767            helpers_mapping.is_some(),
4768            "helpers.py should be in mappings (fallback); got {:?}",
4769            result
4770                .iter()
4771                .map(|m| &m.production_file)
4772                .collect::<Vec<_>>()
4773        );
4774        assert!(
4775            helpers_mapping.unwrap().test_files.contains(&test_file),
4776            "test_helpers.py should map to helpers.py (fallback, no FN)"
4777        );
4778    }
4779
4780    // -----------------------------------------------------------------------
4781    // PY-AF-10: E2E — third_party_http_client pattern, FP reduction confirmed
4782    //
4783    // Fixture: af_e2e_http/
4784    //   pkg/http_client.py (HttpClient, HttpResponse) <- primary SUT
4785    //   pkg/exceptions.py  (RequestError)             <- incidental (pytest.raises)
4786    //   tests/test_http_client.py: asserts response.ok, response.status_code == 201
4787    //
4788    // HttpClient is reachable via chain (response -> client -> HttpClient).
4789    // exceptions.py: RequestError appears inside pytest.raises() which IS an
4790    // assertion node, so it will be in asserted_imports.
4791    // This test verifies http_client.py is always mapped (no FN on primary SUT).
4792    // -----------------------------------------------------------------------
4793    #[test]
4794    fn py_af_10_e2e_http_client_primary_mapped() {
4795        use std::path::PathBuf;
4796        let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4797            .parent()
4798            .unwrap()
4799            .parent()
4800            .unwrap()
4801            .join("tests/fixtures/python/observe/af_e2e_http");
4802
4803        let test_file = fixture_root
4804            .join("tests/test_http_client.py")
4805            .to_string_lossy()
4806            .into_owned();
4807        let http_client_prod = fixture_root
4808            .join("pkg/http_client.py")
4809            .to_string_lossy()
4810            .into_owned();
4811        let exceptions_prod = fixture_root
4812            .join("pkg/exceptions.py")
4813            .to_string_lossy()
4814            .into_owned();
4815
4816        let production_files = vec![http_client_prod.clone(), exceptions_prod.clone()];
4817        let test_source =
4818            std::fs::read_to_string(&test_file).expect("fixture test file must exist");
4819        let mut test_sources = HashMap::new();
4820        test_sources.insert(test_file.clone(), test_source);
4821
4822        // When: map_test_files_with_imports is called
4823        let extractor = PythonExtractor::new();
4824        let result =
4825            extractor.map_test_files_with_imports(&production_files, &test_sources, &fixture_root);
4826
4827        // Then: http_client.py IS mapped (primary SUT, must not be a FN)
4828        let http_client_mapping = result
4829            .iter()
4830            .find(|m| m.production_file == http_client_prod);
4831        assert!(
4832            http_client_mapping.is_some(),
4833            "http_client.py should be in mappings; got {:?}",
4834            result
4835                .iter()
4836                .map(|m| &m.production_file)
4837                .collect::<Vec<_>>()
4838        );
4839        assert!(
4840            http_client_mapping.unwrap().test_files.contains(&test_file),
4841            "test_http_client.py should map to http_client.py (primary SUT)"
4842        );
4843    }
4844
4845    // -----------------------------------------------------------------------
4846    // PY-E2E-HELPER: test helper excluded from mappings
4847    // -----------------------------------------------------------------------
4848    #[test]
4849    fn py_e2e_helper_excluded_from_mappings() {
4850        // Given: tests/helpers.py is a test helper imported by tests/test_client.py
4851        //        pkg/client.py is the production SUT
4852        let tmp = tempfile::tempdir().unwrap();
4853        let root = tmp.path();
4854
4855        // Write fixture files
4856        let files: &[(&str, &str)] = &[
4857            ("pkg/__init__.py", ""),
4858            ("pkg/client.py", "class Client:\n    def connect(self):\n        return True\n"),
4859            ("tests/__init__.py", ""),
4860            ("tests/helpers.py", "def mock_client():\n    return \"mock\"\n"),
4861            (
4862                "tests/test_client.py",
4863                "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n    client = Client()\n    assert client.connect()\n\ndef test_with_mock():\n    mc = mock_client()\n    assert mc == \"mock\"\n",
4864            ),
4865        ];
4866        for (rel, content) in files {
4867            let path = root.join(rel);
4868            if let Some(parent) = path.parent() {
4869                std::fs::create_dir_all(parent).unwrap();
4870            }
4871            std::fs::write(&path, content).unwrap();
4872        }
4873
4874        let extractor = PythonExtractor::new();
4875
4876        // production_files: pkg/client.py and tests/helpers.py
4877        // (discover_files would put helpers.py in production_files since it's not test_*.py)
4878        let client_abs = root.join("pkg/client.py").to_string_lossy().into_owned();
4879        let helpers_abs = root.join("tests/helpers.py").to_string_lossy().into_owned();
4880        let production_files = vec![client_abs.clone(), helpers_abs.clone()];
4881
4882        let test_abs = root
4883            .join("tests/test_client.py")
4884            .to_string_lossy()
4885            .into_owned();
4886        let test_content = "from pkg.client import Client\nfrom tests.helpers import mock_client\n\ndef test_connect():\n    client = Client()\n    assert client.connect()\n\ndef test_with_mock():\n    mc = mock_client()\n    assert mc == \"mock\"\n";
4887        let test_sources: HashMap<String, String> = [(test_abs.clone(), test_content.to_string())]
4888            .into_iter()
4889            .collect();
4890
4891        // When: map_test_files_with_imports is called
4892        let mappings =
4893            extractor.map_test_files_with_imports(&production_files, &test_sources, root);
4894
4895        // Then: tests/helpers.py should NOT appear as a production_file in any mapping
4896        for m in &mappings {
4897            assert!(
4898                !m.production_file.contains("helpers.py"),
4899                "helpers.py should be excluded as test helper, but found in mapping: {:?}",
4900                m
4901            );
4902        }
4903
4904        // Then: pkg/client.py SHOULD be mapped to test_client.py
4905        let client_mapping = mappings
4906            .iter()
4907            .find(|m| m.production_file.contains("client.py"));
4908        assert!(
4909            client_mapping.is_some(),
4910            "pkg/client.py should be mapped; got {:?}",
4911            mappings
4912                .iter()
4913                .map(|m| &m.production_file)
4914                .collect::<Vec<_>>()
4915        );
4916        let client_mapping = client_mapping.unwrap();
4917        assert!(
4918            client_mapping
4919                .test_files
4920                .iter()
4921                .any(|t| t.contains("test_client.py")),
4922            "pkg/client.py should map to test_client.py; got {:?}",
4923            client_mapping.test_files
4924        );
4925    }
4926
4927    // -----------------------------------------------------------------------
4928    // PY-FP-01: MockTransport fixture re-exported via barrel should NOT be mapped.
4929    //
4930    // is_non_sut_helper excludes mock*.py files from production_files.
4931    // -----------------------------------------------------------------------
4932    #[test]
4933    fn py_fp_01_mock_transport_fixture_not_mapped() {
4934        use tempfile::TempDir;
4935
4936        let dir = TempDir::new().unwrap();
4937        let root = dir.path();
4938        let pkg = root.join("pkg");
4939        let transports = pkg.join("_transports");
4940        let tests_dir = root.join("tests");
4941        std::fs::create_dir_all(&transports).unwrap();
4942        std::fs::create_dir_all(&tests_dir).unwrap();
4943
4944        std::fs::write(
4945            transports.join("mock.py"),
4946            "class MockTransport:\n    pass\n",
4947        )
4948        .unwrap();
4949        std::fs::write(
4950            transports.join("__init__.py"),
4951            "from .mock import MockTransport\n",
4952        )
4953        .unwrap();
4954        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
4955        std::fs::write(
4956            pkg.join("__init__.py"),
4957            "from ._transports import *\nfrom ._client import Client\n",
4958        )
4959        .unwrap();
4960
4961        let test_content = "import pkg\n\ndef test_hooks():\n    client = pkg.Client(transport=pkg.MockTransport())\n    assert client is not None\n";
4962        std::fs::write(tests_dir.join("test_hooks.py"), test_content).unwrap();
4963
4964        let mock_path = transports.join("mock.py").to_string_lossy().into_owned();
4965        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
4966        let test_path = tests_dir
4967            .join("test_hooks.py")
4968            .to_string_lossy()
4969            .into_owned();
4970
4971        let extractor = PythonExtractor::new();
4972        let production_files = vec![mock_path.clone(), client_path.clone()];
4973        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
4974            .into_iter()
4975            .collect();
4976
4977        let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
4978
4979        let mock_mapping = result.iter().find(|m| m.production_file == mock_path);
4980        assert!(
4981            mock_mapping.is_none() || mock_mapping.unwrap().test_files.is_empty(),
4982            "mock.py should NOT be mapped (fixture); mappings={:?}",
4983            result
4984        );
4985    }
4986
4987    // -----------------------------------------------------------------------
4988    // PY-FP-02: __version__.py incidental should NOT be mapped.
4989    //
4990    // is_non_sut_helper excludes __version__.py from production_files.
4991    // -----------------------------------------------------------------------
4992    #[test]
4993    fn py_fp_02_version_py_incidental_not_mapped() {
4994        use tempfile::TempDir;
4995
4996        let dir = TempDir::new().unwrap();
4997        let root = dir.path();
4998        let pkg = root.join("pkg");
4999        let tests_dir = root.join("tests");
5000        std::fs::create_dir_all(&pkg).unwrap();
5001        std::fs::create_dir_all(&tests_dir).unwrap();
5002
5003        std::fs::write(pkg.join("__version__.py"), "__version__ = \"1.0.0\"\n").unwrap();
5004        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5005        std::fs::write(
5006            pkg.join("__init__.py"),
5007            "from .__version__ import __version__\nfrom ._client import Client\n",
5008        )
5009        .unwrap();
5010
5011        let test_content = "import pkg\n\ndef test_headers():\n    expected = f\"python-pkg/{pkg.__version__}\"\n    assert expected == \"python-pkg/1.0.0\"\n";
5012        std::fs::write(tests_dir.join("test_headers.py"), test_content).unwrap();
5013
5014        let version_path = pkg.join("__version__.py").to_string_lossy().into_owned();
5015        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5016        let test_path = tests_dir
5017            .join("test_headers.py")
5018            .to_string_lossy()
5019            .into_owned();
5020
5021        let extractor = PythonExtractor::new();
5022        let production_files = vec![version_path.clone(), client_path.clone()];
5023        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5024            .into_iter()
5025            .collect();
5026
5027        let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
5028
5029        let version_mapping = result.iter().find(|m| m.production_file == version_path);
5030        assert!(
5031            version_mapping.is_none() || version_mapping.unwrap().test_files.is_empty(),
5032            "__version__.py should NOT be mapped (metadata); mappings={:?}",
5033            result
5034        );
5035    }
5036
5037    // -----------------------------------------------------------------------
5038    // PY-FP-03: _types.py type-annotation-only should NOT be mapped.
5039    //
5040    // is_non_sut_helper excludes _types.py from production_files.
5041    // -----------------------------------------------------------------------
5042    #[test]
5043    fn py_fp_03_types_py_annotation_not_mapped() {
5044        use tempfile::TempDir;
5045
5046        let dir = TempDir::new().unwrap();
5047        let root = dir.path();
5048        let pkg = root.join("pkg");
5049        let tests_dir = root.join("tests");
5050        std::fs::create_dir_all(&pkg).unwrap();
5051        std::fs::create_dir_all(&tests_dir).unwrap();
5052
5053        std::fs::write(
5054            pkg.join("_types.py"),
5055            "from typing import Union\nQueryParamTypes = Union[str, dict]\n",
5056        )
5057        .unwrap();
5058        std::fs::write(pkg.join("_client.py"), "class Client:\n    pass\n").unwrap();
5059        std::fs::write(
5060            pkg.join("__init__.py"),
5061            "from ._types import *\nfrom ._client import Client\n",
5062        )
5063        .unwrap();
5064
5065        let test_content = "import pkg\n\ndef test_client():\n    client = pkg.Client()\n    assert client is not None\n";
5066        std::fs::write(tests_dir.join("test_client.py"), test_content).unwrap();
5067
5068        let types_path = pkg.join("_types.py").to_string_lossy().into_owned();
5069        let client_path = pkg.join("_client.py").to_string_lossy().into_owned();
5070        let test_path = tests_dir
5071            .join("test_client.py")
5072            .to_string_lossy()
5073            .into_owned();
5074
5075        let extractor = PythonExtractor::new();
5076        let production_files = vec![types_path.clone(), client_path.clone()];
5077        let test_sources: HashMap<String, String> = [(test_path.clone(), test_content.to_string())]
5078            .into_iter()
5079            .collect();
5080
5081        let result = extractor.map_test_files_with_imports(&production_files, &test_sources, root);
5082
5083        let types_mapping = result.iter().find(|m| m.production_file == types_path);
5084        assert!(
5085            types_mapping.is_none() || types_mapping.unwrap().test_files.is_empty(),
5086            "_types.py should NOT be mapped (type definitions); mappings={:?}",
5087            result
5088        );
5089    }
5090}