Skip to main content

exspec_lang_php/
observe.rs

1use std::collections::HashMap;
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::PhpExtractor;
14
15// ---------------------------------------------------------------------------
16// Route struct (Laravel route extraction)
17// ---------------------------------------------------------------------------
18
19/// A route extracted from a Laravel routes/*.php file.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Route {
22    pub http_method: String,
23    pub path: String,
24    pub handler_name: String,
25    pub class_name: String,
26    pub file: String,
27    pub line: usize,
28}
29
30const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
31static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
32
33const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
34static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35
36const EXTENDS_CLASS_QUERY: &str = include_str!("../queries/extends_class.scm");
37static EXTENDS_CLASS_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38
39fn php_language() -> tree_sitter::Language {
40    tree_sitter_php::LANGUAGE_PHP.into()
41}
42
43fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
44    lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
45}
46
47// ---------------------------------------------------------------------------
48// Stem helpers
49// ---------------------------------------------------------------------------
50
51/// Extract stem from a PHP test file path.
52/// `tests/UserTest.php` -> `Some("User")`   (Test suffix, PHPUnit)
53/// `tests/user_test.php` -> `Some("user")`  (_test suffix, Pest)
54/// `tests/Unit/OrderServiceTest.php` -> `Some("OrderService")`
55/// `src/User.php` -> `None`
56pub fn test_stem(path: &str) -> Option<&str> {
57    let file_name = Path::new(path).file_name()?.to_str()?;
58    // Must end with .php
59    let stem = file_name.strip_suffix(".php")?;
60
61    // *Test.php (PHPUnit convention)
62    if let Some(rest) = stem.strip_suffix("Test") {
63        if !rest.is_empty() {
64            return Some(rest);
65        }
66    }
67
68    // *_test.php (Pest convention)
69    if let Some(rest) = stem.strip_suffix("_test") {
70        if !rest.is_empty() {
71            return Some(rest);
72        }
73    }
74
75    None
76}
77
78/// Extract stem from a PHP production file path.
79/// `src/User.php` -> `Some("User")`
80/// `src/Models/User.php` -> `Some("User")`
81/// `tests/UserTest.php` -> `None`
82pub fn production_stem(path: &str) -> Option<&str> {
83    // Test files are not production files
84    if test_stem(path).is_some() {
85        return None;
86    }
87
88    let file_name = Path::new(path).file_name()?.to_str()?;
89    let stem = file_name.strip_suffix(".php")?;
90
91    if stem.is_empty() {
92        return None;
93    }
94
95    Some(stem)
96}
97
98/// Check if a file is a non-SUT helper (not subject under test).
99pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
100    // If the file is already known to be a production file, it's not a helper.
101    if is_known_production {
102        return false;
103    }
104
105    let normalized = file_path.replace('\\', "/");
106    let file_name = Path::new(&normalized)
107        .file_name()
108        .and_then(|f| f.to_str())
109        .unwrap_or("");
110
111    // TestCase.php (base test class)
112    if file_name == "TestCase.php" {
113        return true;
114    }
115
116    // *Factory.php in tests/ (Laravel factory)
117    if file_name.ends_with("Factory.php") {
118        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
119        if in_tests {
120            return true;
121        }
122    }
123
124    // Abstract*.php in tests/
125    if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
126        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
127        if in_tests {
128            return true;
129        }
130    }
131
132    // Trait*.php or *Trait.php in tests/ (test traits)
133    let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
134    if in_tests
135        && file_name.ends_with(".php")
136        && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
137    {
138        return true;
139    }
140
141    // Files in tests/Traits/ directory
142    if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
143        return true;
144    }
145
146    // Fixtures and Stubs directories under tests/ are test infrastructure, not SUT
147    let lower = normalized.to_lowercase();
148    if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
149        || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
150    {
151        return true;
152    }
153
154    // Kernel.php
155    if file_name == "Kernel.php" {
156        return true;
157    }
158
159    // bootstrap.php or bootstrap/*.php
160    if file_name == "bootstrap.php" {
161        return true;
162    }
163    if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
164        return true;
165    }
166
167    false
168}
169
170// ---------------------------------------------------------------------------
171// PSR-4 prefix resolution
172// ---------------------------------------------------------------------------
173
174/// Load PSR-4 namespace prefix -> directory mappings from composer.json.
175/// Returns a map of namespace prefix (trailing `\` stripped) -> directory (trailing `/` stripped).
176/// Returns an empty map if composer.json is absent or unparseable.
177pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
178    let composer_path = scan_root.join("composer.json");
179    let content = match std::fs::read_to_string(&composer_path) {
180        Ok(s) => s,
181        Err(_) => return HashMap::new(),
182    };
183    let value: serde_json::Value = match serde_json::from_str(&content) {
184        Ok(v) => v,
185        Err(_) => return HashMap::new(),
186    };
187
188    let mut result = HashMap::new();
189
190    // Parse both autoload and autoload-dev psr-4 sections
191    for section in &["autoload", "autoload-dev"] {
192        if let Some(psr4) = value
193            .get(section)
194            .and_then(|a| a.get("psr-4"))
195            .and_then(|p| p.as_object())
196        {
197            for (ns, dir) in psr4 {
198                // Strip trailing backslash from namespace prefix
199                let ns_key = ns.trim_end_matches('\\').to_string();
200                // Strip trailing slash from directory
201                let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
202                if !ns_key.is_empty() {
203                    result.insert(ns_key, dir_val);
204                }
205            }
206        }
207    }
208
209    result
210}
211
212// ---------------------------------------------------------------------------
213// External package detection
214// ---------------------------------------------------------------------------
215
216/// Known external PHP package namespace prefixes to skip during import resolution.
217const EXTERNAL_NAMESPACES: &[&str] = &[
218    "PHPUnit",
219    "Illuminate",
220    "Symfony",
221    "Doctrine",
222    "Mockery",
223    "Carbon",
224    "Pest",
225    "Laravel",
226    "Monolog",
227    "Psr",
228    "GuzzleHttp",
229    "League",
230    "Ramsey",
231    "Spatie",
232    "Nette",
233    "Webmozart",
234    "PhpParser",
235    "SebastianBergmann",
236];
237
238fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
239    let first_segment = namespace.split('/').next().unwrap_or("");
240    let is_known_external = EXTERNAL_NAMESPACES
241        .iter()
242        .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
243
244    if !is_known_external {
245        return false;
246    }
247
248    // If scan_root is provided, check if the namespace source exists locally.
249    // If it does, this is a framework self-test scenario — treat as internal.
250    if let Some(root) = scan_root {
251        for prefix in &["src", "app", "lib", ""] {
252            let candidate = if prefix.is_empty() {
253                root.join(first_segment)
254            } else {
255                root.join(prefix).join(first_segment)
256            };
257            if candidate.is_dir() {
258                return false;
259            }
260        }
261    }
262
263    true
264}
265
266// ---------------------------------------------------------------------------
267// Route extraction helpers (Laravel)
268// ---------------------------------------------------------------------------
269
270/// Known Laravel route HTTP methods.
271const LARAVEL_HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "any"];
272
273/// A prefix group: (prefix_value, group_body_start_byte, group_body_end_byte)
274type PrefixGroup = (String, usize, usize);
275
276/// Walk the AST and collect all `Route::prefix('...')` groups.
277/// Returns a list of (prefix, start_byte, end_byte) for the closure/arrow body.
278fn collect_prefix_groups(node: tree_sitter::Node, src: &[u8]) -> Vec<PrefixGroup> {
279    let mut groups = Vec::new();
280    collect_prefix_groups_recursive(node, src, &mut groups);
281    groups
282}
283
284fn collect_prefix_groups_recursive(
285    node: tree_sitter::Node,
286    src: &[u8],
287    groups: &mut Vec<PrefixGroup>,
288) {
289    // Check if this node is a `Route::prefix(...)->group(...)` call
290    if node.kind() == "member_call_expression" {
291        if let Some((prefix, body_start, body_end)) = try_extract_prefix_group(node, src) {
292            groups.push((prefix, body_start, body_end));
293        }
294    }
295    // Recurse into children
296    let mut cursor = node.walk();
297    for child in node.named_children(&mut cursor) {
298        collect_prefix_groups_recursive(child, src, groups);
299    }
300}
301
302/// Try to extract prefix + group body from a `member_call_expression` node.
303/// Matches: `Route::prefix('admin')->group(function () { ... })`
304/// and also chained: `Route::middleware('auth')->group(...)` -> returns None (no prefix).
305fn try_extract_prefix_group(node: tree_sitter::Node, src: &[u8]) -> Option<(String, usize, usize)> {
306    // node is member_call_expression
307    // structure: object->name(arguments)
308    // object = scoped_call_expression (Route::prefix('admin')) OR another member_call_expression
309    // name = "group"
310    let name_node = node.child_by_field_name("name")?;
311    let method_name = name_node.utf8_text(src).ok()?;
312    if method_name != "group" {
313        return None;
314    }
315
316    // Get the object (should be scoped_call_expression or member_call_expression chain)
317    let object_node = node.child_by_field_name("object")?;
318
319    // Extract prefix from the chain
320    let prefix = extract_prefix_from_chain(object_node, src)?;
321
322    // Get group's closure/arrow body byte range
323    let args_node = node.child_by_field_name("arguments")?;
324    let body_range = find_closure_body_range(args_node, src)?;
325
326    Some((prefix, body_range.0, body_range.1))
327}
328
329/// Recursively extract the prefix string from a method chain.
330/// `Route::prefix('admin')` -> Some("admin")
331/// `Route::middleware('auth')` -> None (not a prefix call)
332/// `Route::prefix('api')->middleware('auth')` -> Some("api")  (still has prefix)
333fn extract_prefix_from_chain(node: tree_sitter::Node, src: &[u8]) -> Option<String> {
334    match node.kind() {
335        "scoped_call_expression" => {
336            // Route::prefix('admin') or Route::middleware('auth')
337            let method_node = node.child_by_field_name("name")?;
338            let method = method_node.utf8_text(src).ok()?;
339            if method == "prefix" {
340                let args = node.child_by_field_name("arguments")?;
341                extract_first_string_arg(args, src)
342            } else {
343                None
344            }
345        }
346        "member_call_expression" => {
347            // Chain like Route::prefix('api')->middleware('auth')
348            // Walk back to find if there's a prefix call in the chain
349            let object_node = node.child_by_field_name("object")?;
350            extract_prefix_from_chain(object_node, src)
351        }
352        _ => None,
353    }
354}
355
356/// Find the byte range of the closure/arrow function body inside arguments.
357/// PHP uses `anonymous_function` and `arrow_function` node kinds.
358fn find_closure_body_range(args_node: tree_sitter::Node, _src: &[u8]) -> Option<(usize, usize)> {
359    // Walk through argument nodes to find the closure/arrow function
360    let mut cursor = args_node.walk();
361    for child in args_node.named_children(&mut cursor) {
362        // Direct closure/arrow
363        if let Some(range) = closure_node_range(child) {
364            return Some(range);
365        }
366        // Wrapped in argument node
367        if child.kind() == "argument" {
368            let mut ac = child.walk();
369            for grandchild in child.named_children(&mut ac) {
370                if let Some(range) = closure_node_range(grandchild) {
371                    return Some(range);
372                }
373            }
374        }
375    }
376    None
377}
378
379fn closure_node_range(node: tree_sitter::Node) -> Option<(usize, usize)> {
380    match node.kind() {
381        "anonymous_function" | "arrow_function" | "closure_expression" => {
382            Some((node.start_byte(), node.end_byte()))
383        }
384        _ => None,
385    }
386}
387
388/// Extract the first string literal argument from an arguments node.
389fn extract_first_string_arg(args_node: tree_sitter::Node, src: &[u8]) -> Option<String> {
390    let mut cursor = args_node.walk();
391    for child in args_node.named_children(&mut cursor) {
392        if child.kind() == "encapsed_string" || child.kind() == "string" {
393            let raw = child.utf8_text(src).ok()?;
394            return Some(strip_php_string_quotes(raw));
395        }
396        // Also check argument nodes that wrap the string
397        if child.kind() == "argument" {
398            let mut child_cursor = child.walk();
399            for grandchild in child.named_children(&mut child_cursor) {
400                if grandchild.kind() == "encapsed_string" || grandchild.kind() == "string" {
401                    let raw = grandchild.utf8_text(src).ok()?;
402                    return Some(strip_php_string_quotes(raw));
403                }
404            }
405        }
406    }
407    None
408}
409
410/// Strip PHP string quotes: `'admin'` -> `admin`, `"admin"` -> `admin`
411fn strip_php_string_quotes(s: &str) -> String {
412    let s = s.trim();
413    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
414        s[1..s.len() - 1].to_string()
415    } else {
416        s.to_string()
417    }
418}
419
420/// Walk AST and collect route calls (Route::get/post/etc.) into routes vec.
421fn collect_routes(
422    node: tree_sitter::Node,
423    src: &[u8],
424    file_path: &str,
425    prefix_groups: &[PrefixGroup],
426    routes: &mut Vec<Route>,
427) {
428    // Check if this is a scoped_call_expression: Route::<method>(...)
429    if node.kind() == "scoped_call_expression" {
430        if let Some(route) = try_extract_route(node, src, file_path, prefix_groups) {
431            routes.push(route);
432        }
433    }
434
435    let mut cursor = node.walk();
436    for child in node.named_children(&mut cursor) {
437        collect_routes(child, src, file_path, prefix_groups, routes);
438    }
439}
440
441/// Try to extract a Route from a scoped_call_expression node.
442fn try_extract_route(
443    node: tree_sitter::Node,
444    src: &[u8],
445    file_path: &str,
446    prefix_groups: &[PrefixGroup],
447) -> Option<Route> {
448    // Check scope == "Route"
449    let scope_node = node.child_by_field_name("scope")?;
450    let scope = scope_node.utf8_text(src).ok()?;
451    if scope != "Route" {
452        return None;
453    }
454
455    // Check method is a known HTTP method
456    let method_node = node.child_by_field_name("name")?;
457    let method = method_node.utf8_text(src).ok()?;
458    if !LARAVEL_HTTP_METHODS.contains(&method) {
459        return None;
460    }
461
462    let http_method = method.to_uppercase();
463    let line = node.start_position().row + 1;
464    let byte_offset = node.start_byte();
465
466    // Extract path and handler from arguments
467    let args_node = node.child_by_field_name("arguments")?;
468    let (path_raw, handler_name, class_name) = extract_route_args(args_node, src)?;
469
470    // Resolve prefix from groups containing this byte offset
471    let prefix = resolve_prefix(byte_offset, prefix_groups);
472
473    // Combine prefix + path
474    let path = if prefix.is_empty() {
475        path_raw
476    } else {
477        // Remove leading slash from path_raw when combining with prefix
478        let path_part = path_raw.trim_start_matches('/');
479        format!("{prefix}/{path_part}")
480    };
481
482    Some(Route {
483        http_method,
484        path,
485        handler_name,
486        class_name,
487        file: file_path.to_string(),
488        line,
489    })
490}
491
492/// Extract (path, handler_name, class_name) from a route call's arguments.
493fn extract_route_args(
494    args_node: tree_sitter::Node,
495    src: &[u8],
496) -> Option<(String, String, String)> {
497    let args: Vec<tree_sitter::Node> = {
498        let mut cursor = args_node.walk();
499        args_node
500            .named_children(&mut cursor)
501            .filter(|n| n.kind() == "argument" || is_value_node(n.kind()))
502            .collect()
503    };
504
505    // Normalize: unwrap argument nodes
506    let values: Vec<tree_sitter::Node> = args
507        .iter()
508        .flat_map(|n| {
509            if n.kind() == "argument" {
510                let mut c = n.walk();
511                n.named_children(&mut c).collect::<Vec<_>>()
512            } else {
513                vec![*n]
514            }
515        })
516        .collect();
517
518    if values.is_empty() {
519        return None;
520    }
521
522    // First arg: path (string)
523    let path_node = values.first()?;
524    let path_raw = path_node.utf8_text(src).ok()?;
525    let path = strip_php_string_quotes(path_raw);
526
527    // Second arg: handler (array or closure/arrow)
528    let handler_name;
529    let class_name;
530
531    if let Some(handler_node) = values.get(1) {
532        match handler_node.kind() {
533            "array_creation_expression" => {
534                // [ControllerClass::class, 'method']
535                let (cls, method) = extract_controller_array(*handler_node, src);
536                class_name = cls;
537                handler_name = method;
538            }
539            "closure_expression" | "arrow_function" | "anonymous_class" => {
540                class_name = String::new();
541                handler_name = String::new();
542            }
543            _ => {
544                class_name = String::new();
545                handler_name = String::new();
546            }
547        }
548    } else {
549        class_name = String::new();
550        handler_name = String::new();
551    }
552
553    Some((path, handler_name, class_name))
554}
555
556fn is_value_node(kind: &str) -> bool {
557    matches!(
558        kind,
559        "encapsed_string"
560            | "string"
561            | "array_creation_expression"
562            | "closure_expression"
563            | "arrow_function"
564            | "anonymous_class"
565            | "name"
566    )
567}
568
569/// Extract (class_name, method_name) from `[ControllerClass::class, 'method']`.
570fn extract_controller_array(array_node: tree_sitter::Node, src: &[u8]) -> (String, String) {
571    let mut cursor = array_node.walk();
572    let elements: Vec<tree_sitter::Node> = array_node
573        .named_children(&mut cursor)
574        .filter(|n| n.kind() == "array_element_initializer")
575        .collect();
576
577    let mut class_name = String::new();
578    let mut method_name = String::new();
579
580    // First element: ControllerClass::class
581    if let Some(elem0) = elements.first() {
582        let mut ec = elem0.walk();
583        for child in elem0.named_children(&mut ec) {
584            if child.kind() == "class_constant_access_expression" {
585                // Structure: scope::class
586                if let Some(scope) = child.child_by_field_name("class") {
587                    class_name = scope.utf8_text(src).unwrap_or("").to_string();
588                    // Remove FQCN prefix: keep only the last segment
589                    if let Some(last) = class_name.rsplit('\\').next() {
590                        class_name = last.to_string();
591                    }
592                } else {
593                    // Fallback: first named child
594                    let mut cc = child.walk();
595                    let first_child_text: Option<String> = child
596                        .named_children(&mut cc)
597                        .next()
598                        .and_then(|n| n.utf8_text(src).ok())
599                        .map(|s| s.to_string());
600                    drop(cc);
601                    if let Some(raw) = first_child_text {
602                        if let Some(last) = raw.rsplit('\\').next() {
603                            class_name = last.to_string();
604                        }
605                    }
606                }
607                break;
608            }
609        }
610    }
611
612    // Second element: 'method'
613    if let Some(elem1) = elements.get(1) {
614        let mut ec = elem1.walk();
615        for child in elem1.named_children(&mut ec) {
616            if child.kind() == "encapsed_string" || child.kind() == "string" {
617                let raw = child.utf8_text(src).unwrap_or("");
618                method_name = strip_php_string_quotes(raw);
619                break;
620            }
621        }
622    }
623
624    (class_name, method_name)
625}
626
627/// Find the accumulated prefix for a given byte offset within prefix groups.
628/// Groups are sorted by start byte; outermost first, innermost last.
629fn resolve_prefix(byte_offset: usize, groups: &[PrefixGroup]) -> String {
630    // Collect all groups that contain this byte offset
631    let mut containing: Vec<&PrefixGroup> = groups
632        .iter()
633        .filter(|(_, start, end)| byte_offset > *start && byte_offset < *end)
634        .collect();
635
636    if containing.is_empty() {
637        return String::new();
638    }
639
640    // Sort by start byte (outermost first)
641    containing.sort_by_key(|(_, start, _)| *start);
642
643    // Accumulate prefixes from outermost to innermost
644    containing
645        .iter()
646        .map(|(p, _, _)| p.as_str())
647        .collect::<Vec<_>>()
648        .join("/")
649}
650
651// ---------------------------------------------------------------------------
652// ObserveExtractor impl
653// ---------------------------------------------------------------------------
654
655impl ObserveExtractor for PhpExtractor {
656    fn extract_production_functions(
657        &self,
658        source: &str,
659        file_path: &str,
660    ) -> Vec<ProductionFunction> {
661        let mut parser = Self::parser();
662        let tree = match parser.parse(source, None) {
663            Some(t) => t,
664            None => return Vec::new(),
665        };
666        let source_bytes = source.as_bytes();
667        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
668
669        let name_idx = query.capture_index_for_name("name");
670        let class_name_idx = query.capture_index_for_name("class_name");
671        let method_name_idx = query.capture_index_for_name("method_name");
672        let function_idx = query.capture_index_for_name("function");
673        let method_idx = query.capture_index_for_name("method");
674
675        let mut cursor = QueryCursor::new();
676        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
677        let mut result = Vec::new();
678
679        while let Some(m) = matches.next() {
680            let mut fn_name: Option<String> = None;
681            let mut class_name: Option<String> = None;
682            let mut line: usize = 1;
683            let mut is_exported = true; // default: top-level functions are exported
684            let mut method_node: Option<tree_sitter::Node> = None;
685
686            for cap in m.captures {
687                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
688                let node_line = cap.node.start_position().row + 1;
689
690                if name_idx == Some(cap.index) {
691                    fn_name = Some(text);
692                    line = node_line;
693                } else if class_name_idx == Some(cap.index) {
694                    class_name = Some(text);
695                } else if method_name_idx == Some(cap.index) {
696                    fn_name = Some(text);
697                    line = node_line;
698                }
699
700                // Capture method node for visibility check
701                if method_idx == Some(cap.index) {
702                    method_node = Some(cap.node);
703                }
704
705                // Top-level function: always exported
706                if function_idx == Some(cap.index) {
707                    is_exported = true;
708                }
709            }
710
711            // Determine visibility from method node
712            if let Some(method) = method_node {
713                is_exported = has_public_visibility(method, source_bytes);
714            }
715
716            if let Some(name) = fn_name {
717                result.push(ProductionFunction {
718                    name,
719                    file: file_path.to_string(),
720                    line,
721                    class_name,
722                    is_exported,
723                });
724            }
725        }
726
727        result
728    }
729
730    fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
731        // PHP has no relative imports; Layer 2 uses PSR-4 namespace resolution
732        Vec::new()
733    }
734
735    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
736        let mut parser = Self::parser();
737        let tree = match parser.parse(source, None) {
738            Some(t) => t,
739            None => return Vec::new(),
740        };
741        let source_bytes = source.as_bytes();
742        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
743
744        let namespace_path_idx = query.capture_index_for_name("namespace_path");
745
746        let mut cursor = QueryCursor::new();
747        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
748
749        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
750
751        while let Some(m) = matches.next() {
752            for cap in m.captures {
753                if namespace_path_idx != Some(cap.index) {
754                    continue;
755                }
756                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
757                // Convert `App\Models\User` -> `App/Models/User`
758                let fs_path = raw.replace('\\', "/");
759
760                // Skip external packages (no scan_root — trait method, conservative filter)
761                if is_external_namespace(&fs_path, None) {
762                    continue;
763                }
764
765                // Split into module path and symbol
766                // `App/Models/User` -> module=`App/Models`, symbol=`User`
767                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
768                if parts.len() < 2 {
769                    // Single segment (no slash): use as both module and symbol
770                    // e.g., `use User;` -> module="", symbol="User"
771                    // Skip these edge cases
772                    continue;
773                }
774
775                // Find the last '/' to split module from symbol
776                if let Some(last_slash) = fs_path.rfind('/') {
777                    let module_path = &fs_path[..last_slash];
778                    let symbol = &fs_path[last_slash + 1..];
779                    if !module_path.is_empty() && !symbol.is_empty() {
780                        result_map
781                            .entry(module_path.to_string())
782                            .or_default()
783                            .push(symbol.to_string());
784                    }
785                }
786            }
787        }
788
789        result_map.into_iter().collect()
790    }
791
792    fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
793        // PHP has no barrel export pattern
794        Vec::new()
795    }
796
797    fn source_extensions(&self) -> &[&str] {
798        &["php"]
799    }
800
801    fn index_file_names(&self) -> &[&str] {
802        // PHP has no index files equivalent
803        &[]
804    }
805
806    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
807        production_stem(path)
808    }
809
810    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
811        test_stem(path)
812    }
813
814    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
815        is_non_sut_helper(file_path, is_known_production)
816    }
817}
818
819// ---------------------------------------------------------------------------
820// Concrete methods (not in trait)
821// ---------------------------------------------------------------------------
822
823impl PhpExtractor {
824    /// Extract Laravel routes from a routes/*.php file.
825    pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
826        if source.is_empty() {
827            return Vec::new();
828        }
829
830        let mut parser = Self::parser();
831        let tree = match parser.parse(source, None) {
832            Some(t) => t,
833            None => return Vec::new(),
834        };
835        let source_bytes = source.as_bytes();
836
837        // Collect prefix groups: (prefix_str, group_body_start_byte, group_body_end_byte)
838        let prefix_groups = collect_prefix_groups(tree.root_node(), source_bytes);
839
840        // Walk tree and collect routes
841        let mut routes = Vec::new();
842        collect_routes(
843            tree.root_node(),
844            source_bytes,
845            file_path,
846            &prefix_groups,
847            &mut routes,
848        );
849
850        routes
851    }
852
853    /// Extract all import specifiers without external namespace filtering.
854    /// Returns (module_path, [symbols]) pairs for all `use` statements.
855    fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
856        let mut parser = Self::parser();
857        let tree = match parser.parse(source, None) {
858            Some(t) => t,
859            None => return Vec::new(),
860        };
861        let source_bytes = source.as_bytes();
862        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
863
864        let namespace_path_idx = query.capture_index_for_name("namespace_path");
865
866        let mut cursor = QueryCursor::new();
867        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
868
869        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
870
871        while let Some(m) = matches.next() {
872            for cap in m.captures {
873                if namespace_path_idx != Some(cap.index) {
874                    continue;
875                }
876                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
877                let fs_path = raw.replace('\\', "/");
878
879                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
880                if parts.len() < 2 {
881                    continue;
882                }
883
884                if let Some(last_slash) = fs_path.rfind('/') {
885                    let module_path = &fs_path[..last_slash];
886                    let symbol = &fs_path[last_slash + 1..];
887                    if !module_path.is_empty() && !symbol.is_empty() {
888                        result_map
889                            .entry(module_path.to_string())
890                            .or_default()
891                            .push(symbol.to_string());
892                    }
893                }
894            }
895        }
896
897        result_map.into_iter().collect()
898    }
899
900    /// Extract import specifiers from the parent class of a test file.
901    /// Resolves the parent class to a file in the same directory, reads it,
902    /// and returns its raw `use` statements (unfiltered).
903    /// Only traces 1 level deep (direct parent only).
904    pub fn extract_parent_class_imports(
905        source: &str,
906        test_dir: &str,
907    ) -> Vec<(String, Vec<String>)> {
908        // Step 1: parse source and find extends clause
909        let mut parser = Self::parser();
910        let tree = match parser.parse(source, None) {
911            Some(t) => t,
912            None => return Vec::new(),
913        };
914        let source_bytes = source.as_bytes();
915        let query = cached_query(&EXTENDS_CLASS_QUERY_CACHE, EXTENDS_CLASS_QUERY);
916
917        let parent_class_idx = query.capture_index_for_name("parent_class");
918
919        let mut cursor = QueryCursor::new();
920        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
921
922        let mut parent_class_name: Option<String> = None;
923        while let Some(m) = matches.next() {
924            for cap in m.captures {
925                if parent_class_idx == Some(cap.index) {
926                    let name = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
927                    if !name.is_empty() {
928                        parent_class_name = Some(name);
929                        break;
930                    }
931                }
932            }
933            if parent_class_name.is_some() {
934                break;
935            }
936        }
937
938        let parent_name = match parent_class_name {
939            Some(n) => n,
940            None => return Vec::new(),
941        };
942
943        // Step 2: look for parent file in same directory
944        let parent_file_name = format!("{parent_name}.php");
945        let parent_path = Path::new(test_dir).join(&parent_file_name);
946
947        // Read parent file
948        let parent_source = match std::fs::read_to_string(&parent_path) {
949            Ok(s) => s,
950            Err(_) => return Vec::new(),
951        };
952
953        // Step 3: extract raw import specifiers from parent
954        Self::extract_raw_import_specifiers(&parent_source)
955    }
956
957    /// Layer 1 + Layer 2 (PSR-4): Map test files to production files.
958    pub fn map_test_files_with_imports(
959        &self,
960        production_files: &[String],
961        test_sources: &HashMap<String, String>,
962        scan_root: &Path,
963        l1_exclusive: bool,
964    ) -> Vec<FileMapping> {
965        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
966
967        // Layer 1: filename convention (stem matching)
968        let mut mappings =
969            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
970
971        // Build canonical path -> production index lookup
972        let canonical_root = match scan_root.canonicalize() {
973            Ok(r) => r,
974            Err(_) => return mappings,
975        };
976        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
977        for (idx, prod) in production_files.iter().enumerate() {
978            if let Ok(canonical) = Path::new(prod).canonicalize() {
979                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
980            }
981        }
982
983        // Record Layer 1 matches per production file index
984        let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
985            .iter()
986            .map(|m| m.test_files.iter().cloned().collect())
987            .collect();
988
989        // Collect set of test files matched by L1 for l1_exclusive mode
990        let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
991            .iter()
992            .flat_map(|s| s.iter().cloned())
993            .collect();
994
995        // Load PSR-4 prefix mappings from composer.json (e.g., "MyApp" -> "custom_src")
996        let psr4_prefixes = load_psr4_prefixes(scan_root);
997
998        // Layer 2: PSR-4 convention import resolution
999        // Use raw imports (unfiltered) and apply scan_root-aware external filtering
1000        for (test_file, source) in test_sources {
1001            if l1_exclusive && layer1_matched.contains(test_file) {
1002                continue;
1003            }
1004            let raw_specifiers = Self::extract_raw_import_specifiers(source);
1005            // Merge parent class imports (1-level, same directory only)
1006            let parent_dir = Path::new(test_file.as_str())
1007                .parent()
1008                .map(|p| p.to_string_lossy().into_owned())
1009                .unwrap_or_default();
1010            let parent_specifiers = Self::extract_parent_class_imports(source, &parent_dir);
1011            let combined: Vec<(String, Vec<String>)> = raw_specifiers
1012                .into_iter()
1013                .chain(parent_specifiers.into_iter())
1014                .collect();
1015            let specifiers: Vec<(String, Vec<String>)> = combined
1016                .into_iter()
1017                .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
1018                .collect();
1019            let mut matched_indices = std::collections::HashSet::<usize>::new();
1020
1021            for (module_path, _symbols) in &specifiers {
1022                // PSR-4 resolution:
1023                // `App/Models/User` -> try `src/Models/User.php`, `app/Models/User.php`, etc.
1024                //
1025                // Strategy: strip the first segment (PSR-4 prefix like "App")
1026                // and search for the remaining path under common directories.
1027                let parts: Vec<&str> = module_path.splitn(2, '/').collect();
1028                let first_segment = parts[0];
1029                let path_without_prefix = if parts.len() == 2 {
1030                    parts[1]
1031                } else {
1032                    module_path.as_str()
1033                };
1034
1035                // Check if first segment matches a PSR-4 prefix from composer.json
1036                // e.g., "MyApp" -> "custom_src" means resolve under custom_src/
1037                let psr4_dir = psr4_prefixes.get(first_segment);
1038
1039                // Derive the PHP file name from the last segment of module_path
1040                // e.g., `App/Models` -> last segment is `Models` -> file is `Models.php`
1041                // But module_path is actually the directory, not the file.
1042                // The symbol is in the symbols list, but we need to reconstruct the file path.
1043                // Actually, at this point module_path = `App/Models` and symbol could be `User`,
1044                // so the full file is `Models/User.php` (without prefix).
1045
1046                // We need to get the symbols too
1047                for symbol in _symbols {
1048                    let file_name = format!("{symbol}.php");
1049
1050                    // If composer.json defines a PSR-4 mapping for this namespace prefix,
1051                    // try the mapped directory first.
1052                    if let Some(psr4_base) = psr4_dir {
1053                        let candidate = canonical_root
1054                            .join(psr4_base)
1055                            .join(path_without_prefix)
1056                            .join(&file_name);
1057                        if let Ok(canonical_candidate) = candidate.canonicalize() {
1058                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1059                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1060                                matched_indices.insert(idx);
1061                            }
1062                        }
1063                    }
1064
1065                    // Try: <scan_root>/<common_prefix>/<path_without_prefix>/<symbol>.php
1066                    let common_prefixes = ["src", "app", "lib", ""];
1067                    for prefix in &common_prefixes {
1068                        let candidate = if prefix.is_empty() {
1069                            canonical_root.join(path_without_prefix).join(&file_name)
1070                        } else {
1071                            canonical_root
1072                                .join(prefix)
1073                                .join(path_without_prefix)
1074                                .join(&file_name)
1075                        };
1076
1077                        if let Ok(canonical_candidate) = candidate.canonicalize() {
1078                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1079                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1080                                matched_indices.insert(idx);
1081                            }
1082                        }
1083                    }
1084
1085                    // Also try with the first segment kept (in case directory matches namespace 1:1)
1086                    // e.g., framework self-tests: `Illuminate/Http` -> `src/Illuminate/Http/Request.php`
1087                    for prefix in &common_prefixes {
1088                        let candidate = if prefix.is_empty() {
1089                            canonical_root.join(module_path).join(&file_name)
1090                        } else {
1091                            canonical_root
1092                                .join(prefix)
1093                                .join(module_path)
1094                                .join(&file_name)
1095                        };
1096                        if let Ok(canonical_candidate) = candidate.canonicalize() {
1097                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1098                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1099                                matched_indices.insert(idx);
1100                            }
1101                        }
1102                    }
1103                }
1104            }
1105
1106            for idx in matched_indices {
1107                if !mappings[idx].test_files.contains(test_file) {
1108                    mappings[idx].test_files.push(test_file.clone());
1109                }
1110            }
1111        }
1112
1113        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
1114        // set strategy to ImportTracing
1115        for (i, mapping) in mappings.iter_mut().enumerate() {
1116            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
1117            if !has_layer1 && !mapping.test_files.is_empty() {
1118                mapping.strategy = MappingStrategy::ImportTracing;
1119            }
1120        }
1121
1122        mappings
1123    }
1124}
1125
1126// ---------------------------------------------------------------------------
1127// Visibility helper
1128// ---------------------------------------------------------------------------
1129
1130/// Check if a PHP method_declaration node has `public` visibility.
1131/// Returns true for public, false for private/protected.
1132/// If no visibility_modifier child is found, defaults to true (public by convention in PHP).
1133fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
1134    for i in 0..node.child_count() {
1135        if let Some(child) = node.child(i) {
1136            if child.kind() == "visibility_modifier" {
1137                let text = child.utf8_text(source_bytes).unwrap_or("");
1138                return text == "public";
1139            }
1140        }
1141    }
1142    // No visibility modifier -> treat as public by default
1143    true
1144}
1145
1146// ---------------------------------------------------------------------------
1147// Tests
1148// ---------------------------------------------------------------------------
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153    use std::collections::HashMap;
1154
1155    // -----------------------------------------------------------------------
1156    // PHP-STEM-01: tests/UserTest.php -> test_stem = Some("User")
1157    // -----------------------------------------------------------------------
1158    #[test]
1159    fn php_stem_01_test_suffix() {
1160        // Given: a file named UserTest.php in tests/
1161        // When: test_stem is called
1162        // Then: returns Some("User")
1163        assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
1164    }
1165
1166    // -----------------------------------------------------------------------
1167    // PHP-STEM-02: tests/user_test.php -> test_stem = Some("user")
1168    // -----------------------------------------------------------------------
1169    #[test]
1170    fn php_stem_02_pest_suffix() {
1171        // Given: a Pest-style file user_test.php
1172        // When: test_stem is called
1173        // Then: returns Some("user")
1174        assert_eq!(test_stem("tests/user_test.php"), Some("user"));
1175    }
1176
1177    // -----------------------------------------------------------------------
1178    // PHP-STEM-03: tests/Unit/OrderServiceTest.php -> test_stem = Some("OrderService")
1179    // -----------------------------------------------------------------------
1180    #[test]
1181    fn php_stem_03_nested() {
1182        // Given: a nested test file OrderServiceTest.php
1183        // When: test_stem is called
1184        // Then: returns Some("OrderService")
1185        assert_eq!(
1186            test_stem("tests/Unit/OrderServiceTest.php"),
1187            Some("OrderService")
1188        );
1189    }
1190
1191    // -----------------------------------------------------------------------
1192    // PHP-STEM-04: src/User.php -> test_stem = None
1193    // -----------------------------------------------------------------------
1194    #[test]
1195    fn php_stem_04_non_test() {
1196        // Given: a production file src/User.php
1197        // When: test_stem is called
1198        // Then: returns None
1199        assert_eq!(test_stem("src/User.php"), None);
1200    }
1201
1202    // -----------------------------------------------------------------------
1203    // PHP-STEM-05: src/User.php -> production_stem = Some("User")
1204    // -----------------------------------------------------------------------
1205    #[test]
1206    fn php_stem_05_prod_stem() {
1207        // Given: a production file src/User.php
1208        // When: production_stem is called
1209        // Then: returns Some("User")
1210        assert_eq!(production_stem("src/User.php"), Some("User"));
1211    }
1212
1213    // -----------------------------------------------------------------------
1214    // PHP-STEM-06: src/Models/User.php -> production_stem = Some("User")
1215    // -----------------------------------------------------------------------
1216    #[test]
1217    fn php_stem_06_prod_nested() {
1218        // Given: a nested production file src/Models/User.php
1219        // When: production_stem is called
1220        // Then: returns Some("User")
1221        assert_eq!(production_stem("src/Models/User.php"), Some("User"));
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // PHP-STEM-07: tests/UserTest.php -> production_stem = None
1226    // -----------------------------------------------------------------------
1227    #[test]
1228    fn php_stem_07_test_not_prod() {
1229        // Given: a test file tests/UserTest.php
1230        // When: production_stem is called
1231        // Then: returns None (test files are not production files)
1232        assert_eq!(production_stem("tests/UserTest.php"), None);
1233    }
1234
1235    // -----------------------------------------------------------------------
1236    // PHP-HELPER-01: tests/TestCase.php -> is_non_sut_helper = true
1237    // -----------------------------------------------------------------------
1238    #[test]
1239    fn php_helper_01_test_case() {
1240        // Given: the base test class TestCase.php
1241        // When: is_non_sut_helper is called
1242        // Then: returns true
1243        assert!(is_non_sut_helper("tests/TestCase.php", false));
1244    }
1245
1246    // -----------------------------------------------------------------------
1247    // PHP-HELPER-02: tests/UserFactory.php -> is_non_sut_helper = true
1248    // -----------------------------------------------------------------------
1249    #[test]
1250    fn php_helper_02_factory() {
1251        // Given: a Laravel factory file in tests/
1252        // When: is_non_sut_helper is called
1253        // Then: returns true
1254        assert!(is_non_sut_helper("tests/UserFactory.php", false));
1255    }
1256
1257    // -----------------------------------------------------------------------
1258    // PHP-HELPER-03: src/User.php -> is_non_sut_helper = false
1259    // -----------------------------------------------------------------------
1260    #[test]
1261    fn php_helper_03_production() {
1262        // Given: a regular production file
1263        // When: is_non_sut_helper is called
1264        // Then: returns false
1265        assert!(!is_non_sut_helper("src/User.php", false));
1266    }
1267
1268    // -----------------------------------------------------------------------
1269    // PHP-HELPER-04: tests/Traits/CreatesUsers.php -> is_non_sut_helper = true
1270    // -----------------------------------------------------------------------
1271    #[test]
1272    fn php_helper_04_test_trait() {
1273        // Given: a test trait in tests/Traits/
1274        // When: is_non_sut_helper is called
1275        // Then: returns true
1276        assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
1277    }
1278
1279    // -----------------------------------------------------------------------
1280    // PHP-HELPER-05: bootstrap/app.php -> is_non_sut_helper = true
1281    // -----------------------------------------------------------------------
1282    #[test]
1283    fn php_helper_05_bootstrap() {
1284        // Given: a bootstrap file
1285        // When: is_non_sut_helper is called
1286        // Then: returns true
1287        assert!(is_non_sut_helper("bootstrap/app.php", false));
1288    }
1289
1290    // -----------------------------------------------------------------------
1291    // PHP-FUNC-01: public function createUser() -> name="createUser", is_exported=true
1292    // -----------------------------------------------------------------------
1293    #[test]
1294    fn php_func_01_public_method() {
1295        // Given: a class with a public method
1296        // When: extract_production_functions is called
1297        // Then: name="createUser", is_exported=true
1298        let ext = PhpExtractor::new();
1299        let source = "<?php\nclass User {\n    public function createUser() {}\n}";
1300        let fns = ext.extract_production_functions(source, "src/User.php");
1301        let f = fns.iter().find(|f| f.name == "createUser").unwrap();
1302        assert!(f.is_exported);
1303    }
1304
1305    // -----------------------------------------------------------------------
1306    // PHP-FUNC-02: private function helper() -> name="helper", is_exported=false
1307    // -----------------------------------------------------------------------
1308    #[test]
1309    fn php_func_02_private_method() {
1310        // Given: a class with a private method
1311        // When: extract_production_functions is called
1312        // Then: name="helper", is_exported=false
1313        let ext = PhpExtractor::new();
1314        let source = "<?php\nclass User {\n    private function helper() {}\n}";
1315        let fns = ext.extract_production_functions(source, "src/User.php");
1316        let f = fns.iter().find(|f| f.name == "helper").unwrap();
1317        assert!(!f.is_exported);
1318    }
1319
1320    // -----------------------------------------------------------------------
1321    // PHP-FUNC-03: class User { public function save() } -> class_name=Some("User")
1322    // -----------------------------------------------------------------------
1323    #[test]
1324    fn php_func_03_class_method() {
1325        // Given: a class User with a public method save()
1326        // When: extract_production_functions is called
1327        // Then: name="save", class_name=Some("User")
1328        let ext = PhpExtractor::new();
1329        let source = "<?php\nclass User {\n    public function save() {}\n}";
1330        let fns = ext.extract_production_functions(source, "src/User.php");
1331        let f = fns.iter().find(|f| f.name == "save").unwrap();
1332        assert_eq!(f.class_name, Some("User".to_string()));
1333    }
1334
1335    // -----------------------------------------------------------------------
1336    // PHP-FUNC-04: function global_helper() (top-level) -> exported
1337    // -----------------------------------------------------------------------
1338    #[test]
1339    fn php_func_04_top_level_function() {
1340        // Given: a top-level function global_helper()
1341        // When: extract_production_functions is called
1342        // Then: name="global_helper", is_exported=true
1343        let ext = PhpExtractor::new();
1344        let source = "<?php\nfunction global_helper() {\n    return 42;\n}";
1345        let fns = ext.extract_production_functions(source, "src/helpers.php");
1346        let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
1347        assert!(f.is_exported);
1348        assert_eq!(f.class_name, None);
1349    }
1350
1351    // -----------------------------------------------------------------------
1352    // PHP-IMP-01: use App\Models\User; -> ("App/Models", ["User"])
1353    // -----------------------------------------------------------------------
1354    #[test]
1355    fn php_imp_01_app_models() {
1356        // Given: a use statement for App\Models\User
1357        // When: extract_all_import_specifiers is called
1358        // Then: returns ("App/Models", ["User"])
1359        let ext = PhpExtractor::new();
1360        let source = "<?php\nuse App\\Models\\User;\n";
1361        let imports = ext.extract_all_import_specifiers(source);
1362        assert!(
1363            imports
1364                .iter()
1365                .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
1366            "expected App/Models -> [User], got: {imports:?}"
1367        );
1368    }
1369
1370    // -----------------------------------------------------------------------
1371    // PHP-IMP-02: use App\Services\UserService; -> ("App/Services", ["UserService"])
1372    // -----------------------------------------------------------------------
1373    #[test]
1374    fn php_imp_02_app_services() {
1375        // Given: a use statement for App\Services\UserService
1376        // When: extract_all_import_specifiers is called
1377        // Then: returns ("App/Services", ["UserService"])
1378        let ext = PhpExtractor::new();
1379        let source = "<?php\nuse App\\Services\\UserService;\n";
1380        let imports = ext.extract_all_import_specifiers(source);
1381        assert!(
1382            imports
1383                .iter()
1384                .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
1385            "expected App/Services -> [UserService], got: {imports:?}"
1386        );
1387    }
1388
1389    // -----------------------------------------------------------------------
1390    // PHP-IMP-03: use PHPUnit\Framework\TestCase; -> external package -> skipped
1391    // -----------------------------------------------------------------------
1392    #[test]
1393    fn php_imp_03_external_phpunit() {
1394        // Given: a use statement for external PHPUnit package
1395        // When: extract_all_import_specifiers is called
1396        // Then: returns empty (external packages are filtered)
1397        let ext = PhpExtractor::new();
1398        let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
1399        let imports = ext.extract_all_import_specifiers(source);
1400        assert!(
1401            imports.is_empty(),
1402            "external PHPUnit should be filtered, got: {imports:?}"
1403        );
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // PHP-IMP-04: use Illuminate\Http\Request; -> external package -> skipped
1408    // -----------------------------------------------------------------------
1409    #[test]
1410    fn php_imp_04_external_illuminate() {
1411        // Given: a use statement for external Illuminate (Laravel) package
1412        // When: extract_all_import_specifiers is called
1413        // Then: returns empty (external packages are filtered)
1414        let ext = PhpExtractor::new();
1415        let source = "<?php\nuse Illuminate\\Http\\Request;\n";
1416        let imports = ext.extract_all_import_specifiers(source);
1417        assert!(
1418            imports.is_empty(),
1419            "external Illuminate should be filtered, got: {imports:?}"
1420        );
1421    }
1422
1423    // -----------------------------------------------------------------------
1424    // PHP-E2E-01: User.php + UserTest.php in the same directory -> Layer 1 stem match
1425    // -----------------------------------------------------------------------
1426    #[test]
1427    fn php_e2e_01_stem_match() {
1428        // Given: production file User.php and test file UserTest.php in the same directory
1429        // (Layer 1 stem matching works when files share the same parent directory)
1430        // When: map_test_files_with_imports is called
1431        // Then: UserTest.php is matched to User.php via Layer 1 stem matching
1432        let dir = tempfile::tempdir().expect("failed to create tempdir");
1433
1434        let prod_file = dir.path().join("User.php");
1435        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1436
1437        let test_file = dir.path().join("UserTest.php");
1438        std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
1439
1440        let ext = PhpExtractor::new();
1441        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1442        let mut test_sources = HashMap::new();
1443        test_sources.insert(
1444            test_file.to_string_lossy().into_owned(),
1445            "<?php\nclass UserTest extends TestCase {}".to_string(),
1446        );
1447
1448        let mappings =
1449            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1450
1451        assert!(!mappings.is_empty(), "expected at least one mapping");
1452        let user_mapping = mappings
1453            .iter()
1454            .find(|m| m.production_file.contains("User.php"))
1455            .expect("expected User.php in mappings");
1456        assert!(
1457            !user_mapping.test_files.is_empty(),
1458            "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
1459        );
1460    }
1461
1462    // -----------------------------------------------------------------------
1463    // PHP-E2E-02: tests/ServiceTest.php imports use App\Services\OrderService
1464    //             -> Layer 2 PSR-4 import match
1465    // -----------------------------------------------------------------------
1466    #[test]
1467    fn php_e2e_02_import_match() {
1468        // Given: production file app/Services/OrderService.php
1469        //        and test file tests/ServiceTest.php with `use App\Services\OrderService;`
1470        // When: map_test_files_with_imports is called
1471        // Then: ServiceTest.php is matched to OrderService.php via Layer 2 import tracing
1472        let dir = tempfile::tempdir().expect("failed to create tempdir");
1473        let services_dir = dir.path().join("app").join("Services");
1474        std::fs::create_dir_all(&services_dir).unwrap();
1475        let test_dir = dir.path().join("tests");
1476        std::fs::create_dir_all(&test_dir).unwrap();
1477
1478        let prod_file = services_dir.join("OrderService.php");
1479        std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
1480
1481        let test_file = test_dir.join("ServiceTest.php");
1482        let test_source =
1483            "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
1484        std::fs::write(&test_file, test_source).unwrap();
1485
1486        let ext = PhpExtractor::new();
1487        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1488        let mut test_sources = HashMap::new();
1489        test_sources.insert(
1490            test_file.to_string_lossy().into_owned(),
1491            test_source.to_string(),
1492        );
1493
1494        let mappings =
1495            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1496
1497        let order_mapping = mappings
1498            .iter()
1499            .find(|m| m.production_file.contains("OrderService.php"))
1500            .expect("expected OrderService.php in mappings");
1501        assert!(
1502            !order_mapping.test_files.is_empty(),
1503            "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1504        );
1505    }
1506
1507    // -----------------------------------------------------------------------
1508    // PHP-E2E-03: tests/TestCase.php -> helper exclusion
1509    // -----------------------------------------------------------------------
1510    #[test]
1511    fn php_e2e_03_helper_exclusion() {
1512        // Given: a TestCase.php base class in tests/
1513        // When: map_test_files_with_imports is called
1514        // Then: TestCase.php is excluded (is_non_sut_helper = true)
1515        let dir = tempfile::tempdir().expect("failed to create tempdir");
1516        let src_dir = dir.path().join("src");
1517        std::fs::create_dir_all(&src_dir).unwrap();
1518        let test_dir = dir.path().join("tests");
1519        std::fs::create_dir_all(&test_dir).unwrap();
1520
1521        let prod_file = src_dir.join("User.php");
1522        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1523
1524        // TestCase.php should be treated as a helper, not a test file
1525        let test_case_file = test_dir.join("TestCase.php");
1526        std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1527
1528        let ext = PhpExtractor::new();
1529        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1530        let mut test_sources = HashMap::new();
1531        test_sources.insert(
1532            test_case_file.to_string_lossy().into_owned(),
1533            "<?php\nabstract class TestCase {}".to_string(),
1534        );
1535
1536        let mappings =
1537            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1538
1539        // TestCase.php should not be matched to User.php
1540        let user_mapping = mappings
1541            .iter()
1542            .find(|m| m.production_file.contains("User.php"));
1543        if let Some(mapping) = user_mapping {
1544            assert!(
1545                mapping.test_files.is_empty()
1546                    || !mapping
1547                        .test_files
1548                        .iter()
1549                        .any(|t| t.contains("TestCase.php")),
1550                "TestCase.php should not be mapped as a test file for User.php"
1551            );
1552        }
1553    }
1554
1555    // -----------------------------------------------------------------------
1556    // PHP-FW-01: laravel/framework layout -> Illuminate import resolves locally
1557    // -----------------------------------------------------------------------
1558    #[test]
1559    fn php_fw_01_laravel_framework_self_test() {
1560        // Given: laravel/framework layout with src/Illuminate/Http/Request.php
1561        //        and tests/Http/RequestTest.php importing `use Illuminate\Http\Request`
1562        // When: map_test_files_with_imports is called
1563        // Then: RequestTest.php is mapped to Request.php via Layer 2
1564        let dir = tempfile::tempdir().expect("failed to create tempdir");
1565        let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1566        std::fs::create_dir_all(&src_dir).unwrap();
1567        let test_dir = dir.path().join("tests").join("Http");
1568        std::fs::create_dir_all(&test_dir).unwrap();
1569
1570        let prod_file = src_dir.join("Request.php");
1571        std::fs::write(
1572            &prod_file,
1573            "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1574        )
1575        .unwrap();
1576
1577        let test_file = test_dir.join("RequestTest.php");
1578        let test_source =
1579            "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1580        std::fs::write(&test_file, test_source).unwrap();
1581
1582        let ext = PhpExtractor::new();
1583        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1584        let mut test_sources = HashMap::new();
1585        test_sources.insert(
1586            test_file.to_string_lossy().into_owned(),
1587            test_source.to_string(),
1588        );
1589
1590        let mappings =
1591            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1592
1593        let request_mapping = mappings
1594            .iter()
1595            .find(|m| m.production_file.contains("Request.php"))
1596            .expect("expected Request.php in mappings");
1597        assert!(
1598            request_mapping
1599                .test_files
1600                .iter()
1601                .any(|t| t.contains("RequestTest.php")),
1602            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1603            request_mapping.test_files
1604        );
1605    }
1606
1607    // -----------------------------------------------------------------------
1608    // PHP-FW-02: normal app -> Illuminate import still filtered (no local source)
1609    // -----------------------------------------------------------------------
1610    #[test]
1611    fn php_fw_02_normal_app_illuminate_filtered() {
1612        // Given: normal app layout with app/Models/User.php
1613        //        and tests/UserTest.php importing `use Illuminate\Http\Request`
1614        //        (no local Illuminate directory)
1615        // When: map_test_files_with_imports is called
1616        // Then: Illuminate import is NOT resolved (no mapping via import)
1617        let dir = tempfile::tempdir().expect("failed to create tempdir");
1618        let app_dir = dir.path().join("app").join("Models");
1619        std::fs::create_dir_all(&app_dir).unwrap();
1620        let test_dir = dir.path().join("tests");
1621        std::fs::create_dir_all(&test_dir).unwrap();
1622
1623        let prod_file = app_dir.join("User.php");
1624        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1625
1626        // This test imports Illuminate but there's no local Illuminate source
1627        let test_file = test_dir.join("OrderTest.php");
1628        let test_source =
1629            "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1630        std::fs::write(&test_file, test_source).unwrap();
1631
1632        let ext = PhpExtractor::new();
1633        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1634        let mut test_sources = HashMap::new();
1635        test_sources.insert(
1636            test_file.to_string_lossy().into_owned(),
1637            test_source.to_string(),
1638        );
1639
1640        let mappings =
1641            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1642
1643        // User.php should not have OrderTest.php mapped (no stem match, no import match)
1644        let user_mapping = mappings
1645            .iter()
1646            .find(|m| m.production_file.contains("User.php"))
1647            .expect("expected User.php in mappings");
1648        assert!(
1649            !user_mapping
1650                .test_files
1651                .iter()
1652                .any(|t| t.contains("OrderTest.php")),
1653            "Illuminate import should be filtered when no local source exists"
1654        );
1655    }
1656
1657    // -----------------------------------------------------------------------
1658    // PHP-FW-03: PHPUnit import still filtered via integration test (regression)
1659    // -----------------------------------------------------------------------
1660    #[test]
1661    fn php_fw_03_phpunit_still_external() {
1662        // Given: app with src/Calculator.php and tests/CalculatorTest.php
1663        //        importing only `use PHPUnit\Framework\TestCase` (no local PHPUnit source)
1664        // When: map_test_files_with_imports is called
1665        // Then: PHPUnit import does not create a false mapping
1666        let dir = tempfile::tempdir().expect("failed to create tempdir");
1667        let src_dir = dir.path().join("src");
1668        std::fs::create_dir_all(&src_dir).unwrap();
1669        let test_dir = dir.path().join("tests");
1670        std::fs::create_dir_all(&test_dir).unwrap();
1671
1672        let prod_file = src_dir.join("Calculator.php");
1673        std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1674
1675        // Test imports only PHPUnit (external) — no import-based mapping should occur
1676        let test_file = test_dir.join("OtherTest.php");
1677        let test_source =
1678            "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1679        std::fs::write(&test_file, test_source).unwrap();
1680
1681        let ext = PhpExtractor::new();
1682        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1683        let mut test_sources = HashMap::new();
1684        test_sources.insert(
1685            test_file.to_string_lossy().into_owned(),
1686            test_source.to_string(),
1687        );
1688
1689        let mappings =
1690            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1691
1692        let calc_mapping = mappings
1693            .iter()
1694            .find(|m| m.production_file.contains("Calculator.php"))
1695            .expect("expected Calculator.php in mappings");
1696        assert!(
1697            !calc_mapping
1698                .test_files
1699                .iter()
1700                .any(|t| t.contains("OtherTest.php")),
1701            "PHPUnit import should not create a mapping to Calculator.php"
1702        );
1703    }
1704
1705    // -----------------------------------------------------------------------
1706    // PHP-FW-04: symfony/symfony layout -> Symfony import resolves locally
1707    // -----------------------------------------------------------------------
1708    #[test]
1709    fn php_fw_04_symfony_self_test() {
1710        // Given: symfony layout with src/Symfony/Component/HttpFoundation/Request.php
1711        //        and tests/HttpFoundation/RequestTest.php importing
1712        //        `use Symfony\Component\HttpFoundation\Request`
1713        // When: map_test_files_with_imports is called
1714        // Then: RequestTest.php is mapped to Request.php via Layer 2
1715        let dir = tempfile::tempdir().expect("failed to create tempdir");
1716        let src_dir = dir
1717            .path()
1718            .join("src")
1719            .join("Symfony")
1720            .join("Component")
1721            .join("HttpFoundation");
1722        std::fs::create_dir_all(&src_dir).unwrap();
1723        let test_dir = dir.path().join("tests").join("HttpFoundation");
1724        std::fs::create_dir_all(&test_dir).unwrap();
1725
1726        let prod_file = src_dir.join("Request.php");
1727        std::fs::write(
1728            &prod_file,
1729            "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1730        )
1731        .unwrap();
1732
1733        let test_file = test_dir.join("RequestTest.php");
1734        let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1735        std::fs::write(&test_file, test_source).unwrap();
1736
1737        let ext = PhpExtractor::new();
1738        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1739        let mut test_sources = HashMap::new();
1740        test_sources.insert(
1741            test_file.to_string_lossy().into_owned(),
1742            test_source.to_string(),
1743        );
1744
1745        let mappings =
1746            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1747
1748        let request_mapping = mappings
1749            .iter()
1750            .find(|m| m.production_file.contains("Request.php"))
1751            .expect("expected Request.php in mappings");
1752        assert!(
1753            request_mapping
1754                .test_files
1755                .iter()
1756                .any(|t| t.contains("RequestTest.php")),
1757            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1758            request_mapping.test_files
1759        );
1760    }
1761
1762    // -----------------------------------------------------------------------
1763    // PHP-HELPER-06: tests/Fixtures/SomeHelper.php -> is_non_sut_helper = true
1764    // -----------------------------------------------------------------------
1765    #[test]
1766    fn php_helper_06_fixtures_dir() {
1767        // Given: a file in tests/Fixtures/
1768        // When: is_non_sut_helper is called
1769        // Then: returns true (Fixtures are test infrastructure, not SUT)
1770        assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1771    }
1772
1773    // -----------------------------------------------------------------------
1774    // PHP-HELPER-07: tests/Fixtures/nested/Stub.php -> is_non_sut_helper = true
1775    // -----------------------------------------------------------------------
1776    #[test]
1777    fn php_helper_07_fixtures_nested() {
1778        // Given: a file in tests/Fixtures/nested/
1779        // When: is_non_sut_helper is called
1780        // Then: returns true
1781        assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1782    }
1783
1784    // -----------------------------------------------------------------------
1785    // PHP-HELPER-08: tests/Stubs/UserStub.php -> is_non_sut_helper = true
1786    // -----------------------------------------------------------------------
1787    #[test]
1788    fn php_helper_08_stubs_dir() {
1789        // Given: a file in tests/Stubs/
1790        // When: is_non_sut_helper is called
1791        // Then: returns true (Stubs are test infrastructure, not SUT)
1792        assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1793    }
1794
1795    // -----------------------------------------------------------------------
1796    // PHP-HELPER-09: tests/Stubs/nested/FakeRepo.php -> is_non_sut_helper = true
1797    // -----------------------------------------------------------------------
1798    #[test]
1799    fn php_helper_09_stubs_nested() {
1800        // Given: a file in tests/Stubs/nested/
1801        // When: is_non_sut_helper is called
1802        // Then: returns true
1803        assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1804    }
1805
1806    // -----------------------------------------------------------------------
1807    // PHP-HELPER-10: app/Stubs/Template.php -> is_non_sut_helper = false (guard test)
1808    // -----------------------------------------------------------------------
1809    #[test]
1810    fn php_helper_10_non_test_stubs() {
1811        // Given: a file in app/Stubs/ (not under tests/)
1812        // When: is_non_sut_helper is called
1813        // Then: returns false (only tests/ subdirs are filtered)
1814        assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1815    }
1816
1817    // -----------------------------------------------------------------------
1818    // PHP-PSR4-01: custom_src/ prefix via composer.json -> resolution success
1819    // -----------------------------------------------------------------------
1820    #[test]
1821    fn php_psr4_01_composer_json_resolution() {
1822        // Given: a project with composer.json defining PSR-4 autoload:
1823        //   {"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}
1824        //   production file: custom_src/Models/Order.php
1825        //   test file: tests/OrderTest.php with `use MyApp\Models\Order;`
1826        // When: map_test_files_with_imports is called
1827        // Then: OrderTest.php is matched to Order.php via PSR-4 resolution
1828        let dir = tempfile::tempdir().expect("failed to create tempdir");
1829        let custom_src_dir = dir.path().join("custom_src").join("Models");
1830        std::fs::create_dir_all(&custom_src_dir).unwrap();
1831        let test_dir = dir.path().join("tests");
1832        std::fs::create_dir_all(&test_dir).unwrap();
1833
1834        // Write composer.json with custom PSR-4 prefix
1835        let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1836        std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1837
1838        let prod_file = custom_src_dir.join("Order.php");
1839        std::fs::write(
1840            &prod_file,
1841            "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1842        )
1843        .unwrap();
1844
1845        let test_file = test_dir.join("OrderTest.php");
1846        let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1847        std::fs::write(&test_file, test_source).unwrap();
1848
1849        let ext = PhpExtractor::new();
1850        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1851        let mut test_sources = HashMap::new();
1852        test_sources.insert(
1853            test_file.to_string_lossy().into_owned(),
1854            test_source.to_string(),
1855        );
1856
1857        let mappings =
1858            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1859
1860        let order_mapping = mappings
1861            .iter()
1862            .find(|m| m.production_file.contains("Order.php"))
1863            .expect("expected Order.php in mappings");
1864        assert!(
1865            order_mapping
1866                .test_files
1867                .iter()
1868                .any(|t| t.contains("OrderTest.php")),
1869            "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1870            order_mapping.test_files
1871        );
1872    }
1873
1874    // -----------------------------------------------------------------------
1875    // PHP-CLI-01: observe --lang php . -> CLI dispatch verification
1876    // -----------------------------------------------------------------------
1877    #[test]
1878    fn php_cli_01_dispatch() {
1879        // Given: a tempdir with a PHP file
1880        // When: PhpExtractor::map_test_files_with_imports is called on an empty project
1881        // Then: returns an empty (or valid) mapping without panicking
1882        let dir = tempfile::tempdir().expect("failed to create tempdir");
1883        let ext = PhpExtractor::new();
1884        let production_files: Vec<String> = vec![];
1885        let test_sources: HashMap<String, String> = HashMap::new();
1886        let mappings =
1887            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1888        assert!(mappings.is_empty());
1889    }
1890}
1891
1892// ---------------------------------------------------------------------------
1893// PHP-PARENT tests: parent class import propagation (TC-01 to TC-04)
1894// ---------------------------------------------------------------------------
1895
1896#[cfg(test)]
1897mod parent_class_tests {
1898    use super::*;
1899    use std::collections::HashMap;
1900
1901    // -----------------------------------------------------------------------
1902    // TC-01: Given test extends ParentClass in same dir, When parent has
1903    //        `use Illuminate\Foo`, Then Foo is in test's import list
1904    // -----------------------------------------------------------------------
1905    #[test]
1906    fn tc01_parent_imports_propagated_to_child() {
1907        // Given: parent class with Illuminate imports in same directory as child
1908        let dir = tempfile::tempdir().expect("failed to create tempdir");
1909        let test_dir = dir.path().join("tests");
1910        std::fs::create_dir_all(&test_dir).unwrap();
1911
1912        let parent_source = r#"<?php
1913namespace App\Tests;
1914use Illuminate\View\Compilers\BladeCompiler;
1915use Illuminate\Container\Container;
1916use PHPUnit\Framework\TestCase;
1917abstract class AbstractBaseTest extends TestCase {}"#;
1918
1919        let parent_file = test_dir.join("AbstractBaseTest.php");
1920        std::fs::write(&parent_file, parent_source).unwrap();
1921
1922        let child_source = r#"<?php
1923namespace App\Tests;
1924class ChildTest extends AbstractBaseTest {
1925    public function testSomething() { $this->assertTrue(true); }
1926}"#;
1927
1928        // When: extract_parent_class_imports is called on the child
1929        let parent_imports = PhpExtractor::extract_parent_class_imports(
1930            child_source,
1931            &parent_file.parent().unwrap().to_string_lossy(),
1932        );
1933
1934        // Then: Illuminate imports from parent are returned
1935        // (Illuminate is normally external, but parent_class_imports returns raw specifiers
1936        //  from parent file — filtering for L2 is done at the call site)
1937        assert!(
1938            !parent_imports.is_empty(),
1939            "expected parent Illuminate imports to be propagated, got: {parent_imports:?}"
1940        );
1941        let has_blade = parent_imports
1942            .iter()
1943            .any(|(m, _)| m.contains("BladeCompiler") || m.contains("Compilers"));
1944        let has_container = parent_imports.iter().any(|(m, _)| m.contains("Container"));
1945        assert!(
1946            has_blade || has_container,
1947            "expected BladeCompiler or Container in parent imports, got: {parent_imports:?}"
1948        );
1949    }
1950
1951    // -----------------------------------------------------------------------
1952    // TC-02: Given test extends ParentClass, When parent has no production
1953    //        imports, Then no additional imports are added (guard test)
1954    // -----------------------------------------------------------------------
1955    #[test]
1956    fn tc02_parent_with_no_production_imports_adds_nothing() {
1957        // Given: a parent class that only imports PHPUnit (external, no production imports)
1958        let dir = tempfile::tempdir().expect("failed to create tempdir");
1959        let test_dir = dir.path().join("tests");
1960        std::fs::create_dir_all(&test_dir).unwrap();
1961        let app_dir = dir.path().join("app").join("Models");
1962        std::fs::create_dir_all(&app_dir).unwrap();
1963
1964        let parent_source = r#"<?php
1965namespace App\Tests;
1966use PHPUnit\Framework\TestCase;
1967abstract class MinimalBaseTest extends TestCase {}"#;
1968
1969        let parent_file = test_dir.join("MinimalBaseTest.php");
1970        std::fs::write(&parent_file, parent_source).unwrap();
1971
1972        let child_source = r#"<?php
1973namespace App\Tests;
1974use App\Models\Order;
1975class OrderTest extends MinimalBaseTest {
1976    public function testOrder() { $this->assertTrue(true); }
1977}"#;
1978
1979        let child_file = test_dir.join("OrderTest.php");
1980        std::fs::write(&child_file, child_source).unwrap();
1981
1982        let prod_file = app_dir.join("Order.php");
1983        std::fs::write(&prod_file, "<?php\nnamespace App\\Models;\nclass Order {}").unwrap();
1984
1985        let ext = PhpExtractor::new();
1986        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1987        let mut test_sources = HashMap::new();
1988        test_sources.insert(
1989            child_file.to_string_lossy().into_owned(),
1990            child_source.to_string(),
1991        );
1992
1993        // When: map_test_files_with_imports is called (parent has no production imports)
1994        let mappings =
1995            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1996
1997        // Then: OrderTest.php is still matched to Order.php via its own import (L2)
1998        // and parent's lack of production imports does not break anything
1999        let order_mapping = mappings
2000            .iter()
2001            .find(|m| m.production_file.contains("Order.php"))
2002            .expect("expected Order.php in mappings");
2003        assert!(
2004            !order_mapping.test_files.is_empty(),
2005            "expected OrderTest.php to be mapped to Order.php (child's own import), got empty"
2006        );
2007    }
2008
2009    // -----------------------------------------------------------------------
2010    // TC-03: Given test extends external class (PHPUnit\TestCase),
2011    //        When resolve parent, Then skip (external namespace guard)
2012    // -----------------------------------------------------------------------
2013    #[test]
2014    fn tc03_external_parent_class_skipped() {
2015        // Given: a test that extends PHPUnit\Framework\TestCase directly
2016        //        (no local parent file to trace)
2017        let dir = tempfile::tempdir().expect("failed to create tempdir");
2018        let app_dir = dir.path().join("app").join("Services");
2019        std::fs::create_dir_all(&app_dir).unwrap();
2020        let test_dir = dir.path().join("tests");
2021        std::fs::create_dir_all(&test_dir).unwrap();
2022
2023        let prod_file = app_dir.join("PaymentService.php");
2024        std::fs::write(
2025            &prod_file,
2026            "<?php\nnamespace App\\Services;\nclass PaymentService {}",
2027        )
2028        .unwrap();
2029
2030        // This test directly extends TestCase (external), not a local abstract class
2031        let test_source = r#"<?php
2032use PHPUnit\Framework\TestCase;
2033use App\Services\PaymentService;
2034class PaymentServiceTest extends TestCase {
2035    public function testPay() { $this->assertTrue(true); }
2036}"#;
2037        let test_file = test_dir.join("PaymentServiceTest.php");
2038        std::fs::write(&test_file, test_source).unwrap();
2039
2040        let ext = PhpExtractor::new();
2041        let production_files = vec![prod_file.to_string_lossy().into_owned()];
2042        let mut test_sources = HashMap::new();
2043        test_sources.insert(
2044            test_file.to_string_lossy().into_owned(),
2045            test_source.to_string(),
2046        );
2047
2048        // When: map_test_files_with_imports is called
2049        let mappings =
2050            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
2051
2052        // Then: PaymentServiceTest.php is matched to PaymentService.php
2053        //       (its own import works; external parent does not cause errors)
2054        let payment_mapping = mappings
2055            .iter()
2056            .find(|m| m.production_file.contains("PaymentService.php"))
2057            .expect("expected PaymentService.php in mappings");
2058        assert!(
2059            payment_mapping
2060                .test_files
2061                .iter()
2062                .any(|t| t.contains("PaymentServiceTest.php")),
2063            "expected PaymentServiceTest.php mapped via own import; got: {:?}",
2064            payment_mapping.test_files
2065        );
2066        // No panic, no infinite loop = external parent was skipped silently
2067    }
2068
2069    // -----------------------------------------------------------------------
2070    // TC-04: Given circular inheritance (A extends B, B extends A),
2071    //        When extract_parent_class_imports is called, Then no infinite loop
2072    // -----------------------------------------------------------------------
2073    #[test]
2074    fn tc04_circular_inheritance_no_infinite_loop() {
2075        // Given: two files that mutually extend each other (pathological case)
2076        let dir = tempfile::tempdir().expect("failed to create tempdir");
2077        let test_dir = dir.path().join("tests");
2078        std::fs::create_dir_all(&test_dir).unwrap();
2079
2080        let a_source = r#"<?php
2081namespace App\Tests;
2082use App\Models\Foo;
2083class ATest extends BTest {}"#;
2084
2085        let b_source = r#"<?php
2086namespace App\Tests;
2087use App\Models\Bar;
2088class BTest extends ATest {}"#;
2089
2090        let a_file = test_dir.join("ATest.php");
2091        let b_file = test_dir.join("BTest.php");
2092        std::fs::write(&a_file, a_source).unwrap();
2093        std::fs::write(&b_file, b_source).unwrap();
2094
2095        // When: extract_parent_class_imports is called on A (which extends B, which extends A)
2096        // Then: returns without infinite loop (function must complete in finite time)
2097        let result =
2098            PhpExtractor::extract_parent_class_imports(a_source, &test_dir.to_string_lossy());
2099
2100        // The result may be empty or contain Bar; crucially it must NOT hang.
2101        // Just asserting this line is reached proves no infinite loop.
2102        let _ = result;
2103    }
2104
2105    // -----------------------------------------------------------------------
2106    // TC-05: Given Laravel observe after fix, When measure recall, Then R > 90%
2107    // -----------------------------------------------------------------------
2108    #[test]
2109    #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
2110    fn tc05_laravel_recall_above_90_percent() {
2111        // Given: Laravel source tree at /tmp/laravel
2112        // When: observe --lang php is run
2113        // Then: Recall > 90% (parent class import propagation resolves AbstractBladeTestCase FN)
2114        // NOTE: This is a placeholder. Actual measurement is done manually via:
2115        //   cargo run -- observe --lang php --format json /tmp/laravel
2116        // and compared against the ground truth in docs/dogfooding-results.md.
2117        unimplemented!(
2118            "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
2119        );
2120    }
2121
2122    // -----------------------------------------------------------------------
2123    // TC-06: Given Laravel observe after fix, When check precision, Then no new FP
2124    // -----------------------------------------------------------------------
2125    #[test]
2126    #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
2127    fn tc06_laravel_no_new_false_positives() {
2128        // Given: Laravel source tree at /tmp/laravel
2129        // When: observe --lang php is run after parent class propagation is implemented
2130        // Then: Precision >= 96% (no new false positives introduced by parent import merging)
2131        // NOTE: This is a placeholder. Actual measurement is done manually.
2132        unimplemented!(
2133            "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
2134        );
2135    }
2136
2137    // -----------------------------------------------------------------------
2138    // TC-01: Controller array syntax -> Route{ GET, /users, index, UserController }
2139    // -----------------------------------------------------------------------
2140    #[test]
2141    fn tc01_controller_array_syntax() {
2142        // Given: a route file with Route::get pointing to a controller array
2143        let source = r#"<?php
2144use Illuminate\Support\Facades\Route;
2145Route::get('/users', [UserController::class, 'index']);
2146"#;
2147        let extractor = PhpExtractor;
2148
2149        // When: extract_routes is called
2150        let routes = extractor.extract_routes(source, "routes/web.php");
2151
2152        // Then: one route with GET /users, handler=index, class=UserController
2153        assert_eq!(routes.len(), 1);
2154        let r = &routes[0];
2155        assert_eq!(r.http_method, "GET");
2156        assert_eq!(r.path, "/users");
2157        assert_eq!(r.handler_name, "index");
2158        assert_eq!(r.class_name, "UserController");
2159        assert_eq!(r.file, "routes/web.php");
2160    }
2161
2162    // -----------------------------------------------------------------------
2163    // TC-02: Closure handler -> Route{ POST, /users, "", "" }
2164    // -----------------------------------------------------------------------
2165    #[test]
2166    fn tc02_closure_handler() {
2167        // Given: a route file with Route::post using a closure handler
2168        let source = r#"<?php
2169use Illuminate\Support\Facades\Route;
2170Route::post('/users', fn () => response()->json(['ok' => true]));
2171"#;
2172        let extractor = PhpExtractor;
2173
2174        // When: extract_routes is called
2175        let routes = extractor.extract_routes(source, "routes/web.php");
2176
2177        // Then: one route with POST /users, handler="" and class="" (closure, no named handler)
2178        assert_eq!(routes.len(), 1);
2179        let r = &routes[0];
2180        assert_eq!(r.http_method, "POST");
2181        assert_eq!(r.path, "/users");
2182        assert_eq!(r.handler_name, "");
2183        assert_eq!(r.class_name, "");
2184    }
2185
2186    // -----------------------------------------------------------------------
2187    // TC-03: prefix group (depth 1) -> Route{ GET, admin/users, ... }
2188    // -----------------------------------------------------------------------
2189    #[test]
2190    fn tc03_prefix_group_depth1() {
2191        // Given: a route file with a single prefix->group wrapping an inner route
2192        let source = r#"<?php
2193use Illuminate\Support\Facades\Route;
2194Route::prefix('admin')->group(function () {
2195    Route::get('/users', [UserController::class, 'index']);
2196});
2197"#;
2198        let extractor = PhpExtractor;
2199
2200        // When: extract_routes is called
2201        let routes = extractor.extract_routes(source, "routes/web.php");
2202
2203        // Then: one route with resolved path admin/users
2204        assert_eq!(routes.len(), 1);
2205        let r = &routes[0];
2206        assert_eq!(r.http_method, "GET");
2207        assert_eq!(r.path, "admin/users");
2208    }
2209
2210    // -----------------------------------------------------------------------
2211    // TC-04: nested prefix group (depth 2) -> Route{ GET, api/v1/users, ... }
2212    // -----------------------------------------------------------------------
2213    #[test]
2214    fn tc04_nested_prefix_group_depth2() {
2215        // Given: a route file with two nested prefix->group blocks
2216        let source = r#"<?php
2217use Illuminate\Support\Facades\Route;
2218Route::prefix('api')->group(fn() =>
2219    Route::prefix('v1')->group(fn() =>
2220        Route::get('/users', [UserController::class, 'index'])
2221    )
2222);
2223"#;
2224        let extractor = PhpExtractor;
2225
2226        // When: extract_routes is called
2227        let routes = extractor.extract_routes(source, "routes/web.php");
2228
2229        // Then: one route with resolved path api/v1/users
2230        assert_eq!(routes.len(), 1);
2231        let r = &routes[0];
2232        assert_eq!(r.http_method, "GET");
2233        assert_eq!(r.path, "api/v1/users");
2234    }
2235
2236    // -----------------------------------------------------------------------
2237    // TC-05: middleware group (no prefix) -> path unaffected
2238    // -----------------------------------------------------------------------
2239    #[test]
2240    fn tc05_middleware_group_no_prefix_effect() {
2241        // Given: a route file where routes are wrapped in a middleware group only
2242        let source = r#"<?php
2243use Illuminate\Support\Facades\Route;
2244Route::middleware('auth')->group(function () {
2245    Route::get('/dashboard', [DashboardController::class, 'index']);
2246});
2247"#;
2248        let extractor = PhpExtractor;
2249
2250        // When: extract_routes is called
2251        let routes = extractor.extract_routes(source, "routes/web.php");
2252
2253        // Then: one route with path /dashboard (middleware does not alter path)
2254        assert_eq!(routes.len(), 1);
2255        let r = &routes[0];
2256        assert_eq!(r.http_method, "GET");
2257        assert_eq!(r.path, "/dashboard");
2258    }
2259}