Skip to main content

exspec_lang_typescript/
observe.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Node, Query, QueryCursor};
7
8use super::{cached_query, TypeScriptExtractor};
9
10// Re-export core types for backward compatibility
11pub use exspec_core::observe::{
12    BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
13    ProductionFunction,
14};
15
16const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
17static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
18
19const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
20static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
21
22const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
23static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
24
25const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
26static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
27
28/// A route extracted from a NestJS controller.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Route {
31    pub http_method: String,
32    pub path: String,
33    pub handler_name: String,
34    pub class_name: String,
35    pub file: String,
36    pub line: usize,
37}
38
39/// A gap-relevant decorator extracted from source code.
40#[derive(Debug, Clone, PartialEq)]
41pub struct DecoratorInfo {
42    pub name: String,
43    pub arguments: Vec<String>,
44    pub target_name: String,
45    pub class_name: String,
46    pub file: String,
47    pub line: usize,
48}
49
50/// HTTP method decorators recognized as route indicators.
51const HTTP_METHODS: &[&str] = &["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"];
52
53/// Decorators relevant to gap analysis (guard/pipe/validation).
54const GAP_RELEVANT_DECORATORS: &[&str] = &[
55    "UseGuards",
56    "UsePipes",
57    "IsEmail",
58    "IsNotEmpty",
59    "MinLength",
60    "MaxLength",
61    "IsOptional",
62    "IsString",
63    "IsNumber",
64    "IsInt",
65    "IsBoolean",
66    "IsDate",
67    "IsEnum",
68    "IsArray",
69    "ValidateNested",
70    "Min",
71    "Max",
72    "Matches",
73    "IsUrl",
74    "IsUUID",
75];
76
77impl ObserveExtractor for TypeScriptExtractor {
78    fn extract_production_functions(
79        &self,
80        source: &str,
81        file_path: &str,
82    ) -> Vec<ProductionFunction> {
83        self.extract_production_functions_impl(source, file_path)
84    }
85
86    fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
87        self.extract_imports_impl(source, file_path)
88    }
89
90    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
91        self.extract_all_import_specifiers_impl(source)
92    }
93
94    fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport> {
95        self.extract_barrel_re_exports_impl(source, file_path)
96    }
97
98    fn source_extensions(&self) -> &[&str] {
99        &["ts", "tsx", "js", "jsx"]
100    }
101
102    fn index_file_names(&self) -> &[&str] {
103        &["index.ts", "index.tsx"]
104    }
105
106    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
107        production_stem(path)
108    }
109
110    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
111        test_stem(path)
112    }
113
114    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
115        is_non_sut_helper(file_path, is_known_production)
116    }
117
118    fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
119        file_exports_any_symbol(file_path, symbols)
120    }
121}
122
123impl TypeScriptExtractor {
124    /// Layer 1: Map test files to production files by filename convention.
125    pub fn map_test_files(
126        &self,
127        production_files: &[String],
128        test_files: &[String],
129    ) -> Vec<FileMapping> {
130        exspec_core::observe::map_test_files(self, production_files, test_files)
131    }
132
133    /// Extract NestJS routes from a controller source file.
134    pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
135        let mut parser = Self::parser();
136        let tree = match parser.parse(source, None) {
137            Some(t) => t,
138            None => return Vec::new(),
139        };
140        let source_bytes = source.as_bytes();
141
142        let mut routes = Vec::new();
143
144        // Find all class declarations (including exported ones)
145        for node in iter_children(tree.root_node()) {
146            // Find class_declaration and its parent (for decorator search)
147            let (container, class_node) = match node.kind() {
148                "export_statement" => {
149                    let cls = node
150                        .named_children(&mut node.walk())
151                        .find(|c| c.kind() == "class_declaration");
152                    match cls {
153                        Some(c) => (node, c),
154                        None => continue,
155                    }
156                }
157                "class_declaration" => (node, node),
158                _ => continue,
159            };
160
161            // @Controller decorator may be on container (export_statement) or class_declaration
162            let (base_path, class_name) =
163                match extract_controller_info(container, class_node, source_bytes) {
164                    Some(info) => info,
165                    None => continue,
166                };
167
168            let class_body = match class_node.child_by_field_name("body") {
169                Some(b) => b,
170                None => continue,
171            };
172
173            let mut decorator_acc: Vec<Node> = Vec::new();
174            for child in iter_children(class_body) {
175                match child.kind() {
176                    "decorator" => decorator_acc.push(child),
177                    "method_definition" => {
178                        let handler_name = child
179                            .child_by_field_name("name")
180                            .and_then(|n| n.utf8_text(source_bytes).ok())
181                            .unwrap_or("")
182                            .to_string();
183                        let line = child.start_position().row + 1;
184
185                        for dec in &decorator_acc {
186                            if let Some((dec_name, dec_arg)) =
187                                extract_decorator_call(*dec, source_bytes)
188                            {
189                                if HTTP_METHODS.contains(&dec_name.as_str()) {
190                                    let sub_path = dec_arg.unwrap_or_default();
191                                    routes.push(Route {
192                                        http_method: dec_name.to_uppercase(),
193                                        path: normalize_path(&base_path, &sub_path),
194                                        handler_name: handler_name.clone(),
195                                        class_name: class_name.clone(),
196                                        file: file_path.to_string(),
197                                        line,
198                                    });
199                                }
200                            }
201                        }
202                        decorator_acc.clear();
203                    }
204                    _ => {}
205                }
206            }
207        }
208
209        routes
210    }
211
212    /// Extract gap-relevant decorators (guards, pipes, validators) from source.
213    pub fn extract_decorators(&self, source: &str, file_path: &str) -> Vec<DecoratorInfo> {
214        let mut parser = Self::parser();
215        let tree = match parser.parse(source, None) {
216            Some(t) => t,
217            None => return Vec::new(),
218        };
219        let source_bytes = source.as_bytes();
220
221        let mut decorators = Vec::new();
222
223        for node in iter_children(tree.root_node()) {
224            let (container, class_node) = match node.kind() {
225                "export_statement" => {
226                    let cls = node
227                        .named_children(&mut node.walk())
228                        .find(|c| c.kind() == "class_declaration");
229                    match cls {
230                        Some(c) => (node, c),
231                        None => continue,
232                    }
233                }
234                "class_declaration" => (node, node),
235                _ => continue,
236            };
237
238            let class_name = class_node
239                .child_by_field_name("name")
240                .and_then(|n| n.utf8_text(source_bytes).ok())
241                .unwrap_or("")
242                .to_string();
243
244            // BLOCK 1 fix: extract class-level gap-relevant decorators
245            // Decorators on the class/container (e.g., @UseGuards at class level)
246            let class_level_decorators: Vec<Node> = find_decorators_on_node(container, class_node);
247            collect_gap_decorators(
248                &class_level_decorators,
249                &class_name, // target_name = class name for class-level
250                &class_name,
251                file_path,
252                source_bytes,
253                &mut decorators,
254            );
255
256            let class_body = match class_node.child_by_field_name("body") {
257                Some(b) => b,
258                None => continue,
259            };
260
261            let mut decorator_acc: Vec<Node> = Vec::new();
262            for child in iter_children(class_body) {
263                match child.kind() {
264                    "decorator" => decorator_acc.push(child),
265                    "method_definition" => {
266                        let method_name = child
267                            .child_by_field_name("name")
268                            .and_then(|n| n.utf8_text(source_bytes).ok())
269                            .unwrap_or("")
270                            .to_string();
271
272                        collect_gap_decorators(
273                            &decorator_acc,
274                            &method_name,
275                            &class_name,
276                            file_path,
277                            source_bytes,
278                            &mut decorators,
279                        );
280                        decorator_acc.clear();
281                    }
282                    // DTO field definitions: decorators are children of the field node
283                    "public_field_definition" => {
284                        let field_name = child
285                            .child_by_field_name("name")
286                            .and_then(|n| n.utf8_text(source_bytes).ok())
287                            .unwrap_or("")
288                            .to_string();
289
290                        let field_decorators: Vec<Node> = iter_children(child)
291                            .filter(|c| c.kind() == "decorator")
292                            .collect();
293                        collect_gap_decorators(
294                            &field_decorators,
295                            &field_name,
296                            &class_name,
297                            file_path,
298                            source_bytes,
299                            &mut decorators,
300                        );
301                        decorator_acc.clear();
302                    }
303                    _ => {}
304                }
305            }
306        }
307
308        decorators
309    }
310
311    fn extract_production_functions_impl(
312        &self,
313        source: &str,
314        file_path: &str,
315    ) -> Vec<ProductionFunction> {
316        let mut parser = Self::parser();
317        let tree = match parser.parse(source, None) {
318            Some(t) => t,
319            None => return Vec::new(),
320        };
321
322        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
323        let mut cursor = QueryCursor::new();
324        let source_bytes = source.as_bytes();
325
326        let idx_name = query
327            .capture_index_for_name("name")
328            .expect("@name capture not found in production_function.scm");
329        let idx_exported_function = query
330            .capture_index_for_name("exported_function")
331            .expect("@exported_function capture not found");
332        let idx_function = query
333            .capture_index_for_name("function")
334            .expect("@function capture not found");
335        let idx_method = query
336            .capture_index_for_name("method")
337            .expect("@method capture not found");
338        let idx_exported_arrow = query
339            .capture_index_for_name("exported_arrow")
340            .expect("@exported_arrow capture not found");
341        let idx_arrow = query
342            .capture_index_for_name("arrow")
343            .expect("@arrow capture not found");
344
345        // Use HashMap keyed by (line, name) to deduplicate overlapping patterns.
346        // Exported patterns and non-exported patterns match the same node;
347        // match order is implementation-dependent, so we upgrade is_exported
348        // to true if any pattern marks it exported.
349        let mut dedup: HashMap<(usize, String), ProductionFunction> = HashMap::new();
350
351        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
352        while let Some(m) = matches.next() {
353            let name_node = match m.captures.iter().find(|c| c.index == idx_name) {
354                Some(c) => c.node,
355                None => continue,
356            };
357            let name = name_node.utf8_text(source_bytes).unwrap_or("").to_string();
358            // Use the @name node's line for consistent deduplication across patterns
359            let line = name_node.start_position().row + 1; // 1-indexed
360
361            let (is_exported, class_name) = if m
362                .captures
363                .iter()
364                .any(|c| c.index == idx_exported_function || c.index == idx_exported_arrow)
365            {
366                (true, None)
367            } else if m
368                .captures
369                .iter()
370                .any(|c| c.index == idx_function || c.index == idx_arrow)
371            {
372                (false, None)
373            } else if let Some(c) = m.captures.iter().find(|c| c.index == idx_method) {
374                let (cname, exported) = find_class_info(c.node, source_bytes);
375                (exported, cname)
376            } else {
377                continue;
378            };
379
380            dedup
381                .entry((line, name.clone()))
382                .and_modify(|existing| {
383                    if is_exported {
384                        existing.is_exported = true;
385                    }
386                })
387                .or_insert(ProductionFunction {
388                    name,
389                    file: file_path.to_string(),
390                    line,
391                    class_name,
392                    is_exported,
393                });
394        }
395
396        let mut results: Vec<ProductionFunction> = dedup.into_values().collect();
397        results.sort_by_key(|f| f.line);
398        results
399    }
400}
401
402/// Iterate over all children of a node (named + anonymous).
403fn iter_children(node: Node) -> impl Iterator<Item = Node> {
404    (0..node.child_count()).filter_map(move |i| node.child(i))
405}
406
407/// Extract @Controller base path and class name.
408/// `container` is the node that holds decorators (export_statement or class_declaration).
409/// `class_node` is the class_declaration itself.
410fn extract_controller_info(
411    container: Node,
412    class_node: Node,
413    source: &[u8],
414) -> Option<(String, String)> {
415    let class_name = class_node
416        .child_by_field_name("name")
417        .and_then(|n| n.utf8_text(source).ok())?
418        .to_string();
419
420    // Look for @Controller decorator in both container and class_node
421    for search_node in [container, class_node] {
422        for i in 0..search_node.child_count() {
423            let child = match search_node.child(i) {
424                Some(c) => c,
425                None => continue,
426            };
427            if child.kind() != "decorator" {
428                continue;
429            }
430            if let Some((name, arg)) = extract_decorator_call(child, source) {
431                if name == "Controller" {
432                    let base_path = arg.unwrap_or_default();
433                    return Some((base_path, class_name));
434                }
435            }
436        }
437    }
438    None
439}
440
441/// Collect gap-relevant decorators from an accumulator into the output vec.
442fn collect_gap_decorators(
443    decorator_acc: &[Node],
444    target_name: &str,
445    class_name: &str,
446    file_path: &str,
447    source: &[u8],
448    output: &mut Vec<DecoratorInfo>,
449) {
450    for dec in decorator_acc {
451        if let Some((dec_name, _)) = extract_decorator_call(*dec, source) {
452            if GAP_RELEVANT_DECORATORS.contains(&dec_name.as_str()) {
453                let args = extract_decorator_args(*dec, source);
454                output.push(DecoratorInfo {
455                    name: dec_name,
456                    arguments: args,
457                    target_name: target_name.to_string(),
458                    class_name: class_name.to_string(),
459                    file: file_path.to_string(),
460                    line: dec.start_position().row + 1,
461                });
462            }
463        }
464    }
465}
466
467/// Extract the name and first string argument from a decorator call.
468/// Returns (name, Some(path)) for string literals, (name, Some("<dynamic>")) for
469/// non-literal arguments (variables, objects), and (name, None) for no arguments.
470fn extract_decorator_call(decorator_node: Node, source: &[u8]) -> Option<(String, Option<String>)> {
471    for i in 0..decorator_node.child_count() {
472        let child = match decorator_node.child(i) {
473            Some(c) => c,
474            None => continue,
475        };
476
477        match child.kind() {
478            "call_expression" => {
479                let func_node = child.child_by_field_name("function")?;
480                let name = func_node.utf8_text(source).ok()?.to_string();
481                let args_node = child.child_by_field_name("arguments")?;
482
483                if args_node.named_child_count() == 0 {
484                    // No arguments: @Get()
485                    return Some((name, None));
486                }
487                // Try first string argument
488                let first_string = find_first_string_arg(args_node, source);
489                if first_string.is_some() {
490                    return Some((name, first_string));
491                }
492                // Non-literal argument (variable, object, etc.): mark as dynamic
493                return Some((name, Some("<dynamic>".to_string())));
494            }
495            "identifier" => {
496                let name = child.utf8_text(source).ok()?.to_string();
497                return Some((name, None));
498            }
499            _ => {}
500        }
501    }
502    None
503}
504
505/// Extract all identifier arguments from a decorator call.
506/// e.g., @UseGuards(AuthGuard, RoleGuard) -> ["AuthGuard", "RoleGuard"]
507fn extract_decorator_args(decorator_node: Node, source: &[u8]) -> Vec<String> {
508    let mut args = Vec::new();
509    for i in 0..decorator_node.child_count() {
510        let child = match decorator_node.child(i) {
511            Some(c) => c,
512            None => continue,
513        };
514        if child.kind() == "call_expression" {
515            if let Some(args_node) = child.child_by_field_name("arguments") {
516                for j in 0..args_node.named_child_count() {
517                    if let Some(arg) = args_node.named_child(j) {
518                        if let Ok(text) = arg.utf8_text(source) {
519                            args.push(text.to_string());
520                        }
521                    }
522                }
523            }
524        }
525    }
526    args
527}
528
529/// Find the first string literal argument in an arguments node.
530fn find_first_string_arg(args_node: Node, source: &[u8]) -> Option<String> {
531    for i in 0..args_node.named_child_count() {
532        let arg = args_node.named_child(i)?;
533        if arg.kind() == "string" {
534            let text = arg.utf8_text(source).ok()?;
535            // Strip quotes
536            let stripped = text.trim_matches(|c| c == '\'' || c == '"');
537            if !stripped.is_empty() {
538                return Some(stripped.to_string());
539            }
540        }
541    }
542    None
543}
544
545/// Normalize and combine base path and sub path.
546/// e.g., ("users", ":id") -> "/users/:id"
547/// e.g., ("", "health") -> "/health"
548/// e.g., ("api/v1/users", "") -> "/api/v1/users"
549fn normalize_path(base: &str, sub: &str) -> String {
550    let base = base.trim_matches('/');
551    let sub = sub.trim_matches('/');
552    match (base.is_empty(), sub.is_empty()) {
553        (true, true) => "/".to_string(),
554        (true, false) => format!("/{sub}"),
555        (false, true) => format!("/{base}"),
556        (false, false) => format!("/{base}/{sub}"),
557    }
558}
559
560/// Collect decorator nodes from both container and class_node.
561/// For `export class`, decorators are on the export_statement, not class_declaration.
562fn find_decorators_on_node<'a>(container: Node<'a>, class_node: Node<'a>) -> Vec<Node<'a>> {
563    let mut result = Vec::new();
564    for search_node in [container, class_node] {
565        for i in 0..search_node.child_count() {
566            if let Some(child) = search_node.child(i) {
567                if child.kind() == "decorator" {
568                    result.push(child);
569                }
570            }
571        }
572    }
573    result
574}
575
576/// Walk up from a method_definition node to find the containing class name and export status.
577fn find_class_info(method_node: Node, source: &[u8]) -> (Option<String>, bool) {
578    let mut current = method_node.parent();
579    while let Some(node) = current {
580        if node.kind() == "class_body" {
581            if let Some(class_node) = node.parent() {
582                let class_kind = class_node.kind();
583                if class_kind == "class_declaration"
584                    || class_kind == "class"
585                    || class_kind == "abstract_class_declaration"
586                {
587                    let class_name = class_node
588                        .child_by_field_name("name")
589                        .and_then(|n| n.utf8_text(source).ok())
590                        .map(|s| s.to_string());
591
592                    // Check if class is inside an export_statement
593                    let is_exported = class_node
594                        .parent()
595                        .is_some_and(|p| p.kind() == "export_statement");
596
597                    return (class_name, is_exported);
598                }
599            }
600        }
601        current = node.parent();
602    }
603    (None, false)
604}
605
606/// Check if a symbol node belongs to a type-only import.
607/// Handles both `import type { X }` (statement-level) and `import { type X }` (specifier-level).
608fn is_type_only_import(symbol_node: Node) -> bool {
609    // Case 1: `import { type X }` — import_specifier has a "type" child
610    let parent = symbol_node.parent();
611    if let Some(p) = parent {
612        if p.kind() == "import_specifier" {
613            for i in 0..p.child_count() {
614                if let Some(child) = p.child(i) {
615                    if child.kind() == "type" {
616                        return true;
617                    }
618                }
619            }
620        }
621    }
622
623    // Case 2: `import type { X }` — import_statement has a "type" child (before import_clause)
624    // Walk up to import_statement
625    let mut current = Some(symbol_node);
626    while let Some(node) = current {
627        if node.kind() == "import_statement" {
628            for i in 0..node.child_count() {
629                if let Some(child) = node.child(i) {
630                    if child.kind() == "type" {
631                        return true;
632                    }
633                }
634            }
635            break;
636        }
637        current = node.parent();
638    }
639    false
640}
641
642impl TypeScriptExtractor {
643    fn extract_imports_impl(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
644        let mut parser = Self::parser();
645        let tree = match parser.parse(source, None) {
646            Some(t) => t,
647            None => return Vec::new(),
648        };
649        let source_bytes = source.as_bytes();
650        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
651        let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
652        let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
653
654        let mut cursor = QueryCursor::new();
655        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
656        let mut result = Vec::new();
657
658        while let Some(m) = matches.next() {
659            let mut symbol_node = None;
660            let mut symbol = None;
661            let mut specifier = None;
662            let mut symbol_line = 0usize;
663            for cap in m.captures {
664                if cap.index == symbol_idx {
665                    symbol_node = Some(cap.node);
666                    symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
667                    symbol_line = cap.node.start_position().row + 1;
668                } else if cap.index == specifier_idx {
669                    specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
670                }
671            }
672            if let (Some(sym), Some(spec)) = (symbol, specifier) {
673                // Filter: only relative paths (./ or ../)
674                if !spec.starts_with("./") && !spec.starts_with("../") {
675                    continue;
676                }
677
678                // Filter: skip type-only imports
679                // `import type { X }` → import_statement has "type" keyword child
680                // `import { type X }` → import_specifier has "type" keyword child
681                if let Some(snode) = symbol_node {
682                    if is_type_only_import(snode) {
683                        continue;
684                    }
685                }
686
687                result.push(ImportMapping {
688                    symbol_name: sym.to_string(),
689                    module_specifier: spec.to_string(),
690                    file: file_path.to_string(),
691                    line: symbol_line,
692                    symbols: Vec::new(),
693                });
694            }
695        }
696        // Populate `symbols`: for each entry, collect all symbol_names that share the same
697        // module_specifier in this file.
698        let specifier_to_symbols: HashMap<String, Vec<String>> =
699            result.iter().fold(HashMap::new(), |mut acc, im| {
700                acc.entry(im.module_specifier.clone())
701                    .or_default()
702                    .push(im.symbol_name.clone());
703                acc
704            });
705        for im in &mut result {
706            im.symbols = specifier_to_symbols
707                .get(&im.module_specifier)
708                .cloned()
709                .unwrap_or_default();
710        }
711        result
712    }
713
714    fn extract_all_import_specifiers_impl(&self, source: &str) -> Vec<(String, Vec<String>)> {
715        let mut parser = Self::parser();
716        let tree = match parser.parse(source, None) {
717            Some(t) => t,
718            None => return Vec::new(),
719        };
720        let source_bytes = source.as_bytes();
721        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
722        let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
723        let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
724
725        let mut cursor = QueryCursor::new();
726        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
727        // Map specifier -> symbols
728        let mut specifier_symbols: std::collections::HashMap<String, Vec<String>> =
729            std::collections::HashMap::new();
730
731        while let Some(m) = matches.next() {
732            let mut symbol_node = None;
733            let mut symbol = None;
734            let mut specifier = None;
735            for cap in m.captures {
736                if cap.index == symbol_idx {
737                    symbol_node = Some(cap.node);
738                    symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
739                } else if cap.index == specifier_idx {
740                    specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
741                }
742            }
743            if let (Some(sym), Some(spec)) = (symbol, specifier) {
744                // Skip relative imports (already handled by extract_imports)
745                if spec.starts_with("./") || spec.starts_with("../") {
746                    continue;
747                }
748                // Skip type-only imports
749                if let Some(snode) = symbol_node {
750                    if is_type_only_import(snode) {
751                        continue;
752                    }
753                }
754                specifier_symbols
755                    .entry(spec.to_string())
756                    .or_default()
757                    .push(sym.to_string());
758            }
759        }
760
761        specifier_symbols.into_iter().collect()
762    }
763
764    fn extract_barrel_re_exports_impl(
765        &self,
766        source: &str,
767        _file_path: &str,
768    ) -> Vec<BarrelReExport> {
769        let mut parser = Self::parser();
770        let tree = match parser.parse(source, None) {
771            Some(t) => t,
772            None => return Vec::new(),
773        };
774        let source_bytes = source.as_bytes();
775        let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
776
777        let symbol_idx = query.capture_index_for_name("symbol_name");
778        let wildcard_idx = query.capture_index_for_name("wildcard");
779        let ns_wildcard_idx = query.capture_index_for_name("ns_wildcard");
780        let specifier_idx = query
781            .capture_index_for_name("from_specifier")
782            .expect("@from_specifier capture not found in re_export.scm");
783
784        let mut cursor = QueryCursor::new();
785        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
786
787        // Group by match: each match corresponds to one export statement pattern.
788        // Named re-export produces one match per symbol; wildcard produces one match.
789        // We use a HashMap keyed by (from_specifier, is_wildcard) to group named symbols.
790        struct ReExportEntry {
791            symbols: Vec<String>,
792            wildcard: bool,
793            namespace_wildcard: bool,
794        }
795        let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
796
797        while let Some(m) = matches.next() {
798            let mut from_spec = None;
799            let mut sym_name = None;
800            let mut is_wildcard = false;
801            let mut is_ns_wildcard = false;
802
803            for cap in m.captures {
804                if ns_wildcard_idx == Some(cap.index) {
805                    is_wildcard = true;
806                    is_ns_wildcard = true;
807                } else if wildcard_idx == Some(cap.index) {
808                    is_wildcard = true;
809                } else if cap.index == specifier_idx {
810                    from_spec = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
811                } else if symbol_idx == Some(cap.index) {
812                    sym_name = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
813                }
814            }
815
816            let Some(spec) = from_spec else { continue };
817
818            let entry = grouped.entry(spec).or_insert(ReExportEntry {
819                symbols: Vec::new(),
820                wildcard: false,
821                namespace_wildcard: false,
822            });
823            if is_wildcard {
824                entry.wildcard = true;
825            }
826            if is_ns_wildcard {
827                entry.namespace_wildcard = true;
828            }
829            if let Some(sym) = sym_name {
830                if !sym.is_empty() && !entry.symbols.contains(&sym) {
831                    entry.symbols.push(sym);
832                }
833            }
834        }
835
836        grouped
837            .into_iter()
838            .map(|(from_spec, entry)| BarrelReExport {
839                symbols: entry.symbols,
840                from_specifier: from_spec,
841                wildcard: entry.wildcard,
842                namespace_wildcard: entry.namespace_wildcard,
843            })
844            .collect()
845    }
846
847    pub fn map_test_files_with_imports(
848        &self,
849        production_files: &[String],
850        test_sources: &HashMap<String, String>,
851        scan_root: &Path,
852        l1_exclusive: bool,
853    ) -> Vec<FileMapping> {
854        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
855
856        // Layer 1: filename convention
857        let mut mappings = self.map_test_files(production_files, &test_file_list);
858
859        // Build canonical path -> production index lookup
860        let canonical_root = match scan_root.canonicalize() {
861            Ok(r) => r,
862            Err(_) => return mappings,
863        };
864        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
865        for (idx, prod) in production_files.iter().enumerate() {
866            if let Ok(canonical) = Path::new(prod).canonicalize() {
867                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
868            }
869        }
870
871        // Collect Layer 1 matched test files
872        let layer1_matched: std::collections::HashSet<String> = mappings
873            .iter()
874            .flat_map(|m| m.test_files.iter().cloned())
875            .collect();
876
877        // Discover and parse tsconfig.json for alias resolution (Layer 2b)
878        let tsconfig_paths =
879            crate::tsconfig::discover_tsconfig(&canonical_root).and_then(|tsconfig_path| {
880                let content = std::fs::read_to_string(&tsconfig_path)
881                    .map_err(|e| {
882                        eprintln!("[exspec] warning: failed to read tsconfig: {e}");
883                    })
884                    .ok()?;
885                let tsconfig_dir = tsconfig_path.parent().unwrap_or(&canonical_root);
886                crate::tsconfig::TsconfigPaths::from_str(&content, tsconfig_dir)
887                    .or_else(|| {
888                        eprintln!("[exspec] warning: failed to parse tsconfig paths, alias resolution disabled");
889                        None
890                    })
891            });
892
893        // Cache for node_modules symlink resolution (shared across all test files)
894        let mut nm_symlink_cache: HashMap<String, Option<PathBuf>> = HashMap::new();
895
896        // Layer 2: import tracing for all test files (Layer 1 matched tests may
897        // also import other production files not matched by filename convention)
898        for (test_file, source) in test_sources {
899            if l1_exclusive && layer1_matched.contains(test_file) {
900                continue;
901            }
902            let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
903            let from_file = Path::new(test_file);
904            let mut matched_indices = std::collections::HashSet::new();
905
906            for import in &imports {
907                if let Some(resolved) = exspec_core::observe::resolve_import_path(
908                    self,
909                    &import.module_specifier,
910                    from_file,
911                    &canonical_root,
912                ) {
913                    exspec_core::observe::collect_import_matches(
914                        self,
915                        &resolved,
916                        &import.symbols,
917                        &canonical_to_idx,
918                        &mut matched_indices,
919                        &canonical_root,
920                    );
921                }
922            }
923
924            // Extract non-relative specifiers once (used by Layer 2b and 2c)
925            let all_specifiers =
926                <Self as ObserveExtractor>::extract_all_import_specifiers(self, source);
927
928            // Layer 2b: tsconfig alias resolution
929            if let Some(ref tc_paths) = tsconfig_paths {
930                for (specifier, symbols) in &all_specifiers {
931                    let Some(alias_base) = tc_paths.resolve_alias(specifier) else {
932                        continue;
933                    };
934                    if let Some(resolved) =
935                        resolve_absolute_base_to_file(self, &alias_base, &canonical_root)
936                    {
937                        exspec_core::observe::collect_import_matches(
938                            self,
939                            &resolved,
940                            symbols,
941                            &canonical_to_idx,
942                            &mut matched_indices,
943                            &canonical_root,
944                        );
945                    }
946                }
947            }
948
949            // Layer 2c: node_modules symlink resolution (monorepo cross-package)
950            // Follow yarn/pnpm workspace symlinks in node_modules to resolve
951            // cross-package imports that are not covered by tsconfig aliases.
952            for (specifier, symbols) in &all_specifiers {
953                // Skip if already resolved by tsconfig alias (Layer 2b)
954                if let Some(ref tc_paths) = tsconfig_paths {
955                    if tc_paths.resolve_alias(specifier).is_some() {
956                        continue;
957                    }
958                }
959                if let Some(resolved_dir) =
960                    resolve_node_modules_symlink(specifier, &canonical_root, &mut nm_symlink_cache)
961                {
962                    // The symlink points to a package root directory (e.g., packages/common).
963                    // Find all production files that are under this resolved directory.
964                    let resolved_dir_str = resolved_dir.to_string_lossy().into_owned();
965                    for prod_canonical in canonical_to_idx.keys() {
966                        if prod_canonical.starts_with(&resolved_dir_str) {
967                            exspec_core::observe::collect_import_matches(
968                                self,
969                                prod_canonical,
970                                symbols,
971                                &canonical_to_idx,
972                                &mut matched_indices,
973                                &canonical_root,
974                            );
975                        }
976                    }
977                }
978            }
979
980            for idx in matched_indices {
981                // Avoid duplicates: skip if already added by Layer 1
982                if !mappings[idx].test_files.contains(test_file) {
983                    mappings[idx].test_files.push(test_file.clone());
984                }
985            }
986        }
987
988        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
989        // set strategy to ImportTracing
990        for mapping in &mut mappings {
991            let has_layer1 = mapping
992                .test_files
993                .iter()
994                .any(|t| layer1_matched.contains(t));
995            if !has_layer1 && !mapping.test_files.is_empty() {
996                mapping.strategy = MappingStrategy::ImportTracing;
997            }
998        }
999
1000        mappings
1001    }
1002}
1003
1004/// Resolve a non-relative specifier by following node_modules symlinks.
1005///
1006/// In yarn/pnpm workspaces, cross-package dependencies are installed as symlinks:
1007///   scan_root/node_modules/@org/common -> ../../packages/common
1008///
1009/// This function:
1010/// 1. Checks cache first (same specifier is reused across test files)
1011/// 2. Builds the path scan_root/node_modules/{specifier}
1012/// 3. Returns Some(canonical_path) only if that path is a symlink (not a real dir)
1013/// 4. Returns None for real directories (npm install), missing paths, or errors
1014///
1015/// Only enabled on Unix (symlink creation requires Unix APIs in tests).
1016fn resolve_node_modules_symlink(
1017    specifier: &str,
1018    scan_root: &Path,
1019    cache: &mut HashMap<String, Option<PathBuf>>,
1020) -> Option<PathBuf> {
1021    if let Some(cached) = cache.get(specifier) {
1022        return cached.clone();
1023    }
1024
1025    let candidate = scan_root.join("node_modules").join(specifier);
1026    let result = match std::fs::symlink_metadata(&candidate) {
1027        Ok(meta) if meta.file_type().is_symlink() => candidate.canonicalize().ok(),
1028        _ => None,
1029    };
1030
1031    cache.insert(specifier.to_string(), result.clone());
1032    result
1033}
1034
1035/// Resolve a module specifier to an absolute file path.
1036/// Thin wrapper over core for backward compatibility.
1037pub fn resolve_import_path(
1038    module_specifier: &str,
1039    from_file: &Path,
1040    scan_root: &Path,
1041) -> Option<String> {
1042    let ext = crate::TypeScriptExtractor::new();
1043    exspec_core::observe::resolve_import_path(&ext, module_specifier, from_file, scan_root)
1044}
1045
1046/// Resolve an already-computed absolute base path. Delegates to core.
1047fn resolve_absolute_base_to_file(
1048    ext: &dyn ObserveExtractor,
1049    base: &Path,
1050    canonical_root: &Path,
1051) -> Option<String> {
1052    exspec_core::observe::resolve_absolute_base_to_file(ext, base, canonical_root)
1053}
1054
1055/// Type definition file: *.enum.*, *.interface.*, *.exception.*
1056/// Returns true if the file has a suffix pattern indicating a type definition.
1057fn is_type_definition_file(file_path: &str) -> bool {
1058    let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1059        return false;
1060    };
1061    if let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str()) {
1062        for suffix in &[".enum", ".interface", ".exception"] {
1063            if stem.ends_with(suffix) && stem != &suffix[1..] {
1064                return true;
1065            }
1066        }
1067    }
1068    false
1069}
1070
1071/// Returns true if the resolved file path is a helper/non-SUT file that should be
1072/// excluded from Layer 2 import tracing.
1073///
1074/// Filtered patterns:
1075/// - Exact filenames: `constants.*`, `index.*`
1076/// - Suffix patterns: `*.enum.*`, `*.interface.*`, `*.exception.*` (skipped when `is_known_production`)
1077/// - Test utility paths: files under `/test/` or `/__tests__/`
1078fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
1079    // Test-utility paths: files under /test/ or /__tests__/ directories.
1080    // Uses segment-based matching to avoid false positives (e.g., "contest/src/foo.ts").
1081    // Note: Windows path separators are intentionally not handled; this tool targets Unix-style paths.
1082    if file_path
1083        .split('/')
1084        .any(|seg| seg == "test" || seg == "__tests__")
1085    {
1086        return true;
1087    }
1088
1089    let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1090        return false;
1091    };
1092
1093    // Exact-match barrel/constant files
1094    if matches!(
1095        file_name,
1096        "constants.ts"
1097            | "constants.js"
1098            | "constants.tsx"
1099            | "constants.jsx"
1100            | "index.ts"
1101            | "index.js"
1102            | "index.tsx"
1103            | "index.jsx"
1104    ) {
1105        return true;
1106    }
1107
1108    // Suffix-match: *.enum.*, *.interface.*, *.exception.*
1109    // When is_known_production=true, type definition files are bypassed
1110    // (they are valid SUT targets when listed in production_files).
1111    if !is_known_production && is_type_definition_file(file_path) {
1112        return true;
1113    }
1114
1115    false
1116}
1117
1118/// Check if a TypeScript file exports any of the given symbol names.
1119/// Used to filter wildcard re-export targets by requested symbols.
1120fn file_exports_any_symbol(file_path: &Path, symbols: &[String]) -> bool {
1121    if symbols.is_empty() {
1122        return true;
1123    }
1124    let source = match std::fs::read_to_string(file_path) {
1125        Ok(s) => s,
1126        Err(_) => return false,
1127    };
1128    let mut parser = TypeScriptExtractor::parser();
1129    let tree = match parser.parse(&source, None) {
1130        Some(t) => t,
1131        None => return false,
1132    };
1133    let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
1134    let symbol_idx = query
1135        .capture_index_for_name("symbol_name")
1136        .expect("@symbol_name capture not found in exported_symbol.scm");
1137
1138    let mut cursor = QueryCursor::new();
1139    let source_bytes = source.as_bytes();
1140    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1141    while let Some(m) = matches.next() {
1142        for cap in m.captures {
1143            if cap.index == symbol_idx {
1144                let name = cap.node.utf8_text(source_bytes).unwrap_or("");
1145                if symbols.iter().any(|s| s == name) {
1146                    return true;
1147                }
1148            }
1149        }
1150    }
1151    false
1152}
1153
1154/// Resolve barrel re-exports. Thin wrapper over core for backward compatibility.
1155pub fn resolve_barrel_exports(
1156    barrel_path: &Path,
1157    symbols: &[String],
1158    scan_root: &Path,
1159) -> Vec<PathBuf> {
1160    let ext = crate::TypeScriptExtractor::new();
1161    exspec_core::observe::resolve_barrel_exports(&ext, barrel_path, symbols, scan_root)
1162}
1163
1164/// HTTP methods recognized in Next.js App Router route handlers.
1165const NEXTJS_HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
1166
1167const NEXTJS_ROUTE_HANDLER_QUERY: &str = include_str!("../queries/nextjs_route_handler.scm");
1168static NEXTJS_ROUTE_HANDLER_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
1169
1170/// Convert a Next.js App Router file path to a route path.
1171///
1172/// Returns `None` if the file is not a `route.ts` / `route.tsx` file.
1173///
1174/// Transformation rules:
1175/// - Strip leading `src/` prefix if present
1176/// - Strip leading `app/` segment
1177/// - Strip `route.ts` / `route.tsx` filename
1178/// - Remove route group segments like `(group)`
1179/// - Convert `[param]` → `:param`
1180/// - Convert `[...slug]` → `:slug*`
1181/// - Convert `[[...slug]]` → `:slug*?`
1182/// - Root route (`app/route.ts`) → `"/"`
1183pub fn file_path_to_route_path(file_path: &str) -> Option<String> {
1184    // Normalize path separators
1185    let normalized = file_path.replace('\\', "/");
1186
1187    // Must end with route.ts or route.tsx
1188    if !normalized.ends_with("/route.ts") && !normalized.ends_with("/route.tsx") {
1189        return None;
1190    }
1191
1192    // Find the `app/` anchor in the path.
1193    // Supports: "app/...", "src/app/...", "/abs/path/to/src/app/...", etc.
1194    let path = if let Some(pos) = normalized.find("/src/app/") {
1195        &normalized[pos + "/src/app/".len()..]
1196    } else if let Some(pos) = normalized.find("/app/") {
1197        &normalized[pos + "/app/".len()..]
1198    } else if let Some(stripped) = normalized.strip_prefix("src/app/") {
1199        stripped
1200    } else if let Some(stripped) = normalized.strip_prefix("app/") {
1201        stripped
1202    } else {
1203        return None;
1204    };
1205
1206    // Remove the trailing route.ts / route.tsx filename
1207    let path = path
1208        .strip_suffix("/route.ts")
1209        .or_else(|| path.strip_suffix("/route.tsx"))
1210        .unwrap_or("");
1211
1212    // Process segments
1213    let mut result = String::new();
1214    for segment in path.split('/') {
1215        if segment.is_empty() {
1216            continue;
1217        }
1218        // Route group: (group) → skip
1219        if segment.starts_with('(') && segment.ends_with(')') {
1220            continue;
1221        }
1222        // Optional catch-all: [[...slug]] → :slug*?
1223        if segment.starts_with("[[...") && segment.ends_with("]]") {
1224            let name = &segment[5..segment.len() - 2];
1225            result.push('/');
1226            result.push(':');
1227            result.push_str(name);
1228            result.push_str("*?");
1229            continue;
1230        }
1231        // Catch-all: [...slug] → :slug*
1232        if segment.starts_with("[...") && segment.ends_with(']') {
1233            let name = &segment[4..segment.len() - 1];
1234            result.push('/');
1235            result.push(':');
1236            result.push_str(name);
1237            result.push('*');
1238            continue;
1239        }
1240        // Dynamic segment: [param] → :param
1241        if segment.starts_with('[') && segment.ends_with(']') {
1242            let name = &segment[1..segment.len() - 1];
1243            result.push('/');
1244            result.push(':');
1245            result.push_str(name);
1246            continue;
1247        }
1248        // Static segment
1249        result.push('/');
1250        result.push_str(segment);
1251    }
1252
1253    if result.is_empty() {
1254        Some("/".to_string())
1255    } else {
1256        Some(result)
1257    }
1258}
1259
1260impl TypeScriptExtractor {
1261    /// Extract Next.js App Router routes from a route handler source file.
1262    ///
1263    /// Returns an empty Vec if `file_path` is not a `route.ts` / `route.tsx` file,
1264    /// or if no HTTP method exports are found.
1265    pub fn extract_nextjs_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
1266        let route_path = match file_path_to_route_path(file_path) {
1267            Some(p) => p,
1268            None => return Vec::new(),
1269        };
1270
1271        if source.is_empty() {
1272            return Vec::new();
1273        }
1274
1275        let mut parser = Self::parser();
1276        let tree = match parser.parse(source, None) {
1277            Some(t) => t,
1278            None => return Vec::new(),
1279        };
1280        let source_bytes = source.as_bytes();
1281
1282        let query = cached_query(
1283            &NEXTJS_ROUTE_HANDLER_QUERY_CACHE,
1284            NEXTJS_ROUTE_HANDLER_QUERY,
1285        );
1286
1287        let mut cursor = QueryCursor::new();
1288        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1289
1290        let handler_name_idx = query
1291            .capture_index_for_name("handler_name")
1292            .expect("nextjs_route_handler.scm must define @handler_name capture");
1293
1294        let mut routes = Vec::new();
1295        while let Some(m) = matches.next() {
1296            for cap in m.captures {
1297                if cap.index == handler_name_idx {
1298                    let name = match cap.node.utf8_text(source_bytes) {
1299                        Ok(n) => n.to_string(),
1300                        Err(_) => continue,
1301                    };
1302                    if NEXTJS_HTTP_METHODS.contains(&name.as_str()) {
1303                        let line = cap.node.start_position().row + 1;
1304                        routes.push(Route {
1305                            http_method: name.clone(),
1306                            path: route_path.clone(),
1307                            handler_name: name,
1308                            class_name: String::new(),
1309                            file: file_path.to_string(),
1310                            line,
1311                        });
1312                    }
1313                }
1314            }
1315        }
1316
1317        routes
1318    }
1319}
1320
1321fn production_stem(path: &str) -> Option<&str> {
1322    Path::new(path).file_stem()?.to_str()
1323}
1324
1325fn test_stem(path: &str) -> Option<&str> {
1326    let stem = Path::new(path).file_stem()?.to_str()?;
1327    stem.strip_suffix(".spec")
1328        .or_else(|| stem.strip_suffix(".test"))
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334
1335    fn fixture(name: &str) -> String {
1336        let path = format!(
1337            "{}/tests/fixtures/typescript/observe/{}",
1338            env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
1339            name
1340        );
1341        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
1342    }
1343
1344    // TC1: exported function declarations are extracted with is_exported: true
1345    #[test]
1346    fn exported_functions_extracted() {
1347        // Given: exported_functions.ts with `export function findAll()` and `export function findById()`
1348        let source = fixture("exported_functions.ts");
1349        let extractor = TypeScriptExtractor::new();
1350
1351        // When: extract production functions
1352        let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1353
1354        // Then: findAll and findById are extracted with is_exported: true
1355        let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1356        let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1357        assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1358        assert!(
1359            names.contains(&"findById"),
1360            "expected findById in {names:?}"
1361        );
1362    }
1363
1364    // TC2: non-exported function has is_exported: false
1365    #[test]
1366    fn non_exported_function_has_flag_false() {
1367        // Given: exported_functions.ts with `function internalHelper()`
1368        let source = fixture("exported_functions.ts");
1369        let extractor = TypeScriptExtractor::new();
1370
1371        // When: extract production functions
1372        let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1373
1374        // Then: internalHelper has is_exported: false
1375        let helper = funcs.iter().find(|f| f.name == "internalHelper");
1376        assert!(helper.is_some(), "expected internalHelper to be extracted");
1377        assert!(!helper.unwrap().is_exported);
1378    }
1379
1380    // TC3: class methods include class_name
1381    #[test]
1382    fn class_methods_with_class_name() {
1383        // Given: class_methods.ts with class UsersController { findAll(), create(), validate() }
1384        let source = fixture("class_methods.ts");
1385        let extractor = TypeScriptExtractor::new();
1386
1387        // When: extract production functions
1388        let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1389
1390        // Then: findAll, create, validate have class_name: Some("UsersController")
1391        let controller_methods: Vec<&ProductionFunction> = funcs
1392            .iter()
1393            .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1394            .collect();
1395        let names: Vec<&str> = controller_methods.iter().map(|f| f.name.as_str()).collect();
1396        assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1397        assert!(names.contains(&"create"), "expected create in {names:?}");
1398        assert!(
1399            names.contains(&"validate"),
1400            "expected validate in {names:?}"
1401        );
1402    }
1403
1404    // TC4: exported class methods are is_exported: true, non-exported class methods are false
1405    #[test]
1406    fn exported_class_is_exported() {
1407        // Given: class_methods.ts with exported UsersController and non-exported InternalService
1408        let source = fixture("class_methods.ts");
1409        let extractor = TypeScriptExtractor::new();
1410
1411        // When: extract production functions
1412        let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1413
1414        // Then: UsersController methods → is_exported: true
1415        let controller_methods: Vec<&ProductionFunction> = funcs
1416            .iter()
1417            .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1418            .collect();
1419        assert!(
1420            controller_methods.iter().all(|f| f.is_exported),
1421            "all UsersController methods should be exported"
1422        );
1423
1424        // Then: InternalService methods → is_exported: false
1425        let internal_methods: Vec<&ProductionFunction> = funcs
1426            .iter()
1427            .filter(|f| f.class_name.as_deref() == Some("InternalService"))
1428            .collect();
1429        assert!(
1430            !internal_methods.is_empty(),
1431            "expected InternalService methods"
1432        );
1433        assert!(
1434            internal_methods.iter().all(|f| !f.is_exported),
1435            "all InternalService methods should not be exported"
1436        );
1437    }
1438
1439    // TC5: arrow function exports are extracted with is_exported: true
1440    #[test]
1441    fn arrow_exports_extracted() {
1442        // Given: arrow_exports.ts with `export const findAll = () => ...`
1443        let source = fixture("arrow_exports.ts");
1444        let extractor = TypeScriptExtractor::new();
1445
1446        // When: extract production functions
1447        let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1448
1449        // Then: findAll, findById are is_exported: true
1450        let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1451        let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1452        assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1453        assert!(
1454            names.contains(&"findById"),
1455            "expected findById in {names:?}"
1456        );
1457    }
1458
1459    // TC6: non-exported arrow function has is_exported: false
1460    #[test]
1461    fn non_exported_arrow_flag_false() {
1462        // Given: arrow_exports.ts with `const internalFn = () => ...`
1463        let source = fixture("arrow_exports.ts");
1464        let extractor = TypeScriptExtractor::new();
1465
1466        // When: extract production functions
1467        let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1468
1469        // Then: internalFn has is_exported: false
1470        let internal = funcs.iter().find(|f| f.name == "internalFn");
1471        assert!(internal.is_some(), "expected internalFn to be extracted");
1472        assert!(!internal.unwrap().is_exported);
1473    }
1474
1475    // TC7: mixed file extracts all types with correct export status
1476    #[test]
1477    fn mixed_file_all_types() {
1478        // Given: mixed.ts with function declarations, arrow functions, and class methods
1479        let source = fixture("mixed.ts");
1480        let extractor = TypeScriptExtractor::new();
1481
1482        // When: extract production functions
1483        let funcs = extractor.extract_production_functions(&source, "mixed.ts");
1484
1485        // Then: all functions are extracted
1486        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1487        // Exported: getUser, createUser, UserService.findAll, UserService.deleteById
1488        assert!(names.contains(&"getUser"), "expected getUser in {names:?}");
1489        assert!(
1490            names.contains(&"createUser"),
1491            "expected createUser in {names:?}"
1492        );
1493        // Non-exported: formatName, validateInput, PrivateHelper.transform
1494        assert!(
1495            names.contains(&"formatName"),
1496            "expected formatName in {names:?}"
1497        );
1498        assert!(
1499            names.contains(&"validateInput"),
1500            "expected validateInput in {names:?}"
1501        );
1502
1503        // Verify export status
1504        let get_user = funcs.iter().find(|f| f.name == "getUser").unwrap();
1505        assert!(get_user.is_exported);
1506        let format_name = funcs.iter().find(|f| f.name == "formatName").unwrap();
1507        assert!(!format_name.is_exported);
1508
1509        // Verify class methods have class_name
1510        let find_all = funcs
1511            .iter()
1512            .find(|f| f.name == "findAll" && f.class_name.is_some())
1513            .unwrap();
1514        assert_eq!(find_all.class_name.as_deref(), Some("UserService"));
1515        assert!(find_all.is_exported);
1516
1517        let transform = funcs.iter().find(|f| f.name == "transform").unwrap();
1518        assert_eq!(transform.class_name.as_deref(), Some("PrivateHelper"));
1519        assert!(!transform.is_exported);
1520    }
1521
1522    // TC8: decorated methods (NestJS) are correctly extracted
1523    #[test]
1524    fn decorated_methods_extracted() {
1525        // Given: nestjs_controller.ts with @Get(), @Post(), @Delete() decorated methods
1526        let source = fixture("nestjs_controller.ts");
1527        let extractor = TypeScriptExtractor::new();
1528
1529        // When: extract production functions
1530        let funcs = extractor.extract_production_functions(&source, "nestjs_controller.ts");
1531
1532        // Then: findAll, create, remove are extracted with class_name and is_exported
1533        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1534        assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1535        assert!(names.contains(&"create"), "expected create in {names:?}");
1536        assert!(names.contains(&"remove"), "expected remove in {names:?}");
1537
1538        for func in &funcs {
1539            assert_eq!(func.class_name.as_deref(), Some("UsersController"));
1540            assert!(func.is_exported);
1541        }
1542    }
1543
1544    // TC9: line numbers match actual source positions
1545    #[test]
1546    fn line_numbers_correct() {
1547        // Given: exported_functions.ts
1548        let source = fixture("exported_functions.ts");
1549        let extractor = TypeScriptExtractor::new();
1550
1551        // When: extract production functions
1552        let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1553
1554        // Then: line numbers correspond to actual positions (1-indexed)
1555        let find_all = funcs.iter().find(|f| f.name == "findAll").unwrap();
1556        assert_eq!(find_all.line, 1, "findAll should be on line 1");
1557
1558        let find_by_id = funcs.iter().find(|f| f.name == "findById").unwrap();
1559        assert_eq!(find_by_id.line, 5, "findById should be on line 5");
1560
1561        let helper = funcs.iter().find(|f| f.name == "internalHelper").unwrap();
1562        assert_eq!(helper.line, 9, "internalHelper should be on line 9");
1563    }
1564
1565    // TC10: empty source returns empty Vec
1566    #[test]
1567    fn empty_source_returns_empty() {
1568        // Given: empty source code
1569        let extractor = TypeScriptExtractor::new();
1570
1571        // When: extract production functions from empty string
1572        let funcs = extractor.extract_production_functions("", "empty.ts");
1573
1574        // Then: returns empty Vec
1575        assert!(funcs.is_empty());
1576    }
1577
1578    // === Route Extraction Tests ===
1579
1580    // RT1: basic NestJS controller routes
1581    #[test]
1582    fn basic_controller_routes() {
1583        // Given: nestjs_controller.ts with @Controller('users') + @Get, @Post, @Delete
1584        let source = fixture("nestjs_controller.ts");
1585        let extractor = TypeScriptExtractor::new();
1586
1587        // When: extract routes
1588        let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1589
1590        // Then: GET /users, POST /users, DELETE /users/:id
1591        assert_eq!(routes.len(), 3, "expected 3 routes, got {routes:?}");
1592        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1593        assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
1594        assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
1595        assert!(
1596            methods.contains(&"DELETE"),
1597            "expected DELETE in {methods:?}"
1598        );
1599
1600        let get_route = routes.iter().find(|r| r.http_method == "GET").unwrap();
1601        assert_eq!(get_route.path, "/users");
1602
1603        let delete_route = routes.iter().find(|r| r.http_method == "DELETE").unwrap();
1604        assert_eq!(delete_route.path, "/users/:id");
1605    }
1606
1607    // RT2: route path combination
1608    #[test]
1609    fn route_path_combination() {
1610        // Given: nestjs_routes_advanced.ts with @Controller('api/v1/users') + @Get('active')
1611        let source = fixture("nestjs_routes_advanced.ts");
1612        let extractor = TypeScriptExtractor::new();
1613
1614        // When: extract routes
1615        let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1616
1617        // Then: GET /api/v1/users/active
1618        let active = routes
1619            .iter()
1620            .find(|r| r.handler_name == "findActive")
1621            .unwrap();
1622        assert_eq!(active.http_method, "GET");
1623        assert_eq!(active.path, "/api/v1/users/active");
1624    }
1625
1626    // RT3: controller with no path argument
1627    #[test]
1628    fn controller_no_path() {
1629        // Given: nestjs_empty_controller.ts with @Controller() + @Get('health')
1630        let source = fixture("nestjs_empty_controller.ts");
1631        let extractor = TypeScriptExtractor::new();
1632
1633        // When: extract routes
1634        let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1635
1636        // Then: GET /health
1637        assert_eq!(routes.len(), 1, "expected 1 route, got {routes:?}");
1638        assert_eq!(routes[0].http_method, "GET");
1639        assert_eq!(routes[0].path, "/health");
1640    }
1641
1642    // RT4: method without route decorator is not extracted
1643    #[test]
1644    fn method_without_route_decorator() {
1645        // Given: nestjs_empty_controller.ts with helperMethod() (no decorator)
1646        let source = fixture("nestjs_empty_controller.ts");
1647        let extractor = TypeScriptExtractor::new();
1648
1649        // When: extract routes
1650        let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1651
1652        // Then: helperMethod is not in routes
1653        let helper = routes.iter().find(|r| r.handler_name == "helperMethod");
1654        assert!(helper.is_none(), "helperMethod should not be a route");
1655    }
1656
1657    // RT5: all HTTP methods
1658    #[test]
1659    fn all_http_methods() {
1660        // Given: nestjs_routes_advanced.ts with Get, Post, Put, Patch, Delete, Head, Options
1661        let source = fixture("nestjs_routes_advanced.ts");
1662        let extractor = TypeScriptExtractor::new();
1663
1664        // When: extract routes
1665        let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1666
1667        // Then: 9 routes (Get appears 3 times)
1668        assert_eq!(routes.len(), 9, "expected 9 routes, got {routes:?}");
1669        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1670        assert!(methods.contains(&"GET"));
1671        assert!(methods.contains(&"POST"));
1672        assert!(methods.contains(&"PUT"));
1673        assert!(methods.contains(&"PATCH"));
1674        assert!(methods.contains(&"DELETE"));
1675        assert!(methods.contains(&"HEAD"));
1676        assert!(methods.contains(&"OPTIONS"));
1677    }
1678
1679    // RT6: UseGuards decorator extraction
1680    #[test]
1681    fn use_guards_decorator() {
1682        // Given: nestjs_guards_pipes.ts with @UseGuards(AuthGuard)
1683        let source = fixture("nestjs_guards_pipes.ts");
1684        let extractor = TypeScriptExtractor::new();
1685
1686        // When: extract decorators
1687        let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1688
1689        // Then: UseGuards with AuthGuard
1690        let guards: Vec<&DecoratorInfo> = decorators
1691            .iter()
1692            .filter(|d| d.name == "UseGuards")
1693            .collect();
1694        assert!(!guards.is_empty(), "expected UseGuards decorators");
1695        let auth_guard = guards
1696            .iter()
1697            .find(|d| d.arguments.contains(&"AuthGuard".to_string()));
1698        assert!(auth_guard.is_some(), "expected AuthGuard argument");
1699    }
1700
1701    // RT7: only gap-relevant decorators (UseGuards, not Delete)
1702    #[test]
1703    fn multiple_decorators_on_method() {
1704        // Given: nestjs_controller.ts with @Delete(':id') @UseGuards(AuthGuard) on remove()
1705        let source = fixture("nestjs_controller.ts");
1706        let extractor = TypeScriptExtractor::new();
1707
1708        // When: extract decorators
1709        let decorators = extractor.extract_decorators(&source, "nestjs_controller.ts");
1710
1711        // Then: UseGuards only (Delete is a route decorator, not gap-relevant)
1712        let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1713        assert!(
1714            names.contains(&"UseGuards"),
1715            "expected UseGuards in {names:?}"
1716        );
1717        assert!(
1718            !names.contains(&"Delete"),
1719            "Delete should not be in decorators"
1720        );
1721    }
1722
1723    // RT8: class-validator decorators on DTO
1724    #[test]
1725    fn class_validator_on_dto() {
1726        // Given: nestjs_dto_validation.ts with @IsEmail, @IsNotEmpty on fields
1727        let source = fixture("nestjs_dto_validation.ts");
1728        let extractor = TypeScriptExtractor::new();
1729
1730        // When: extract decorators
1731        let decorators = extractor.extract_decorators(&source, "nestjs_dto_validation.ts");
1732
1733        // Then: IsEmail and IsNotEmpty extracted
1734        let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1735        assert!(names.contains(&"IsEmail"), "expected IsEmail in {names:?}");
1736        assert!(
1737            names.contains(&"IsNotEmpty"),
1738            "expected IsNotEmpty in {names:?}"
1739        );
1740    }
1741
1742    // RT9: UsePipes decorator
1743    #[test]
1744    fn use_pipes_decorator() {
1745        // Given: nestjs_guards_pipes.ts with @UsePipes(ValidationPipe)
1746        let source = fixture("nestjs_guards_pipes.ts");
1747        let extractor = TypeScriptExtractor::new();
1748
1749        // When: extract decorators
1750        let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1751
1752        // Then: UsePipes with ValidationPipe
1753        let pipes: Vec<&DecoratorInfo> =
1754            decorators.iter().filter(|d| d.name == "UsePipes").collect();
1755        assert!(!pipes.is_empty(), "expected UsePipes decorators");
1756        assert!(pipes[0].arguments.contains(&"ValidationPipe".to_string()));
1757    }
1758
1759    // RT10: empty source returns empty for routes and decorators
1760    #[test]
1761    fn empty_source_returns_empty_routes_and_decorators() {
1762        // Given: empty source
1763        let extractor = TypeScriptExtractor::new();
1764
1765        // When: extract routes and decorators
1766        let routes = extractor.extract_routes("", "empty.ts");
1767        let decorators = extractor.extract_decorators("", "empty.ts");
1768
1769        // Then: both empty
1770        assert!(routes.is_empty());
1771        assert!(decorators.is_empty());
1772    }
1773
1774    // RT11: non-NestJS class returns no routes
1775    #[test]
1776    fn non_nestjs_class_ignored() {
1777        // Given: class_methods.ts (plain class, no @Controller)
1778        let source = fixture("class_methods.ts");
1779        let extractor = TypeScriptExtractor::new();
1780
1781        // When: extract routes
1782        let routes = extractor.extract_routes(&source, "class_methods.ts");
1783
1784        // Then: empty
1785        assert!(routes.is_empty(), "expected no routes from plain class");
1786    }
1787
1788    // RT12: handler_name and class_name correct
1789    #[test]
1790    fn route_handler_and_class_name() {
1791        // Given: nestjs_controller.ts
1792        let source = fixture("nestjs_controller.ts");
1793        let extractor = TypeScriptExtractor::new();
1794
1795        // When: extract routes
1796        let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1797
1798        // Then: handler names and class name correct
1799        let handlers: Vec<&str> = routes.iter().map(|r| r.handler_name.as_str()).collect();
1800        assert!(handlers.contains(&"findAll"));
1801        assert!(handlers.contains(&"create"));
1802        assert!(handlers.contains(&"remove"));
1803        for route in &routes {
1804            assert_eq!(route.class_name, "UsersController");
1805        }
1806    }
1807
1808    // RT13: class-level UseGuards decorator is extracted
1809    #[test]
1810    fn class_level_use_guards() {
1811        // Given: nestjs_guards_pipes.ts with @UseGuards(JwtAuthGuard) at class level
1812        let source = fixture("nestjs_guards_pipes.ts");
1813        let extractor = TypeScriptExtractor::new();
1814
1815        // When: extract decorators
1816        let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1817
1818        // Then: JwtAuthGuard class-level decorator is extracted
1819        let class_guards: Vec<&DecoratorInfo> = decorators
1820            .iter()
1821            .filter(|d| {
1822                d.name == "UseGuards"
1823                    && d.target_name == "ProtectedController"
1824                    && d.class_name == "ProtectedController"
1825            })
1826            .collect();
1827        assert!(
1828            !class_guards.is_empty(),
1829            "expected class-level UseGuards, got {decorators:?}"
1830        );
1831        assert!(class_guards[0]
1832            .arguments
1833            .contains(&"JwtAuthGuard".to_string()));
1834    }
1835
1836    // RT14: non-literal controller path produces <dynamic>
1837    #[test]
1838    fn dynamic_controller_path() {
1839        // Given: nestjs_dynamic_routes.ts with @Controller(BASE_PATH)
1840        let source = fixture("nestjs_dynamic_routes.ts");
1841        let extractor = TypeScriptExtractor::new();
1842
1843        // When: extract routes
1844        let routes = extractor.extract_routes(&source, "nestjs_dynamic_routes.ts");
1845
1846        // Then: path contains <dynamic>
1847        assert_eq!(routes.len(), 1);
1848        assert!(
1849            routes[0].path.contains("<dynamic>"),
1850            "expected <dynamic> in path, got {:?}",
1851            routes[0].path
1852        );
1853    }
1854
1855    // TC11: abstract class methods are extracted with class_name and export status
1856    #[test]
1857    fn abstract_class_methods_extracted() {
1858        // Given: abstract_class.ts with exported and non-exported abstract classes
1859        let source = fixture("abstract_class.ts");
1860        let extractor = TypeScriptExtractor::new();
1861
1862        // When: extract production functions
1863        let funcs = extractor.extract_production_functions(&source, "abstract_class.ts");
1864
1865        // Then: concrete methods are extracted (abstract methods have no body → method_signature, not method_definition)
1866        let validate = funcs.iter().find(|f| f.name == "validate");
1867        assert!(validate.is_some(), "expected validate to be extracted");
1868        let validate = validate.unwrap();
1869        assert_eq!(validate.class_name.as_deref(), Some("BaseService"));
1870        assert!(validate.is_exported);
1871
1872        let process = funcs.iter().find(|f| f.name == "process");
1873        assert!(process.is_some(), "expected process to be extracted");
1874        let process = process.unwrap();
1875        assert_eq!(process.class_name.as_deref(), Some("InternalBase"));
1876        assert!(!process.is_exported);
1877    }
1878
1879    #[test]
1880    fn basic_spec_mapping() {
1881        // Given: a production file and its matching .spec test file in the same directory
1882        let extractor = TypeScriptExtractor::new();
1883        let production_files = vec!["src/users.service.ts".to_string()];
1884        let test_files = vec!["src/users.service.spec.ts".to_string()];
1885
1886        // When: map_test_files is called
1887        let mappings = extractor.map_test_files(&production_files, &test_files);
1888
1889        // Then: the files are matched with FileNameConvention
1890        assert_eq!(
1891            mappings,
1892            vec![FileMapping {
1893                production_file: "src/users.service.ts".to_string(),
1894                test_files: vec!["src/users.service.spec.ts".to_string()],
1895                strategy: MappingStrategy::FileNameConvention,
1896            }]
1897        );
1898    }
1899
1900    #[test]
1901    fn test_suffix_mapping() {
1902        // Given: a production file and its matching .test file
1903        let extractor = TypeScriptExtractor::new();
1904        let production_files = vec!["src/utils.ts".to_string()];
1905        let test_files = vec!["src/utils.test.ts".to_string()];
1906
1907        // When: map_test_files is called
1908        let mappings = extractor.map_test_files(&production_files, &test_files);
1909
1910        // Then: the files are matched
1911        assert_eq!(
1912            mappings[0].test_files,
1913            vec!["src/utils.test.ts".to_string()]
1914        );
1915    }
1916
1917    #[test]
1918    fn multiple_test_files() {
1919        // Given: one production file and both .spec and .test files
1920        let extractor = TypeScriptExtractor::new();
1921        let production_files = vec!["src/app.ts".to_string()];
1922        let test_files = vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()];
1923
1924        // When: map_test_files is called
1925        let mappings = extractor.map_test_files(&production_files, &test_files);
1926
1927        // Then: both test files are matched
1928        assert_eq!(
1929            mappings[0].test_files,
1930            vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()]
1931        );
1932    }
1933
1934    #[test]
1935    fn nestjs_controller() {
1936        // Given: a nested controller file and its matching spec file
1937        let extractor = TypeScriptExtractor::new();
1938        let production_files = vec!["src/users/users.controller.ts".to_string()];
1939        let test_files = vec!["src/users/users.controller.spec.ts".to_string()];
1940
1941        // When: map_test_files is called
1942        let mappings = extractor.map_test_files(&production_files, &test_files);
1943
1944        // Then: the nested files are matched
1945        assert_eq!(
1946            mappings[0].test_files,
1947            vec!["src/users/users.controller.spec.ts".to_string()]
1948        );
1949    }
1950
1951    #[test]
1952    fn no_matching_test() {
1953        // Given: a production file and an unrelated test file
1954        let extractor = TypeScriptExtractor::new();
1955        let production_files = vec!["src/orphan.ts".to_string()];
1956        let test_files = vec!["src/other.spec.ts".to_string()];
1957
1958        // When: map_test_files is called
1959        let mappings = extractor.map_test_files(&production_files, &test_files);
1960
1961        // Then: the production file is still included with no tests
1962        assert_eq!(mappings[0].test_files, Vec::<String>::new());
1963    }
1964
1965    #[test]
1966    fn different_directory_no_match() {
1967        // Given: matching stems in different directories
1968        let extractor = TypeScriptExtractor::new();
1969        let production_files = vec!["src/users.ts".to_string()];
1970        let test_files = vec!["test/users.spec.ts".to_string()];
1971
1972        // When: map_test_files is called
1973        let mappings = extractor.map_test_files(&production_files, &test_files);
1974
1975        // Then: no match is created because Layer 1 is same-directory only
1976        assert_eq!(mappings[0].test_files, Vec::<String>::new());
1977    }
1978
1979    #[test]
1980    fn empty_input() {
1981        // Given: no production files and no test files
1982        let extractor = TypeScriptExtractor::new();
1983
1984        // When: map_test_files is called
1985        let mappings = extractor.map_test_files(&[], &[]);
1986
1987        // Then: an empty vector is returned
1988        assert!(mappings.is_empty());
1989    }
1990
1991    #[test]
1992    fn tsx_files() {
1993        // Given: a TSX production file and its matching test file
1994        let extractor = TypeScriptExtractor::new();
1995        let production_files = vec!["src/App.tsx".to_string()];
1996        let test_files = vec!["src/App.test.tsx".to_string()];
1997
1998        // When: map_test_files is called
1999        let mappings = extractor.map_test_files(&production_files, &test_files);
2000
2001        // Then: the TSX files are matched
2002        assert_eq!(mappings[0].test_files, vec!["src/App.test.tsx".to_string()]);
2003    }
2004
2005    #[test]
2006    fn unmatched_test_ignored() {
2007        // Given: one matching test file and one orphan test file
2008        let extractor = TypeScriptExtractor::new();
2009        let production_files = vec!["src/a.ts".to_string()];
2010        let test_files = vec!["src/a.spec.ts".to_string(), "src/b.spec.ts".to_string()];
2011
2012        // When: map_test_files is called
2013        let mappings = extractor.map_test_files(&production_files, &test_files);
2014
2015        // Then: only the matching test file is included
2016        assert_eq!(mappings.len(), 1);
2017        assert_eq!(mappings[0].test_files, vec!["src/a.spec.ts".to_string()]);
2018    }
2019
2020    #[test]
2021    fn stem_extraction() {
2022        // Given: production and test file paths with ts and tsx extensions
2023        // When: production_stem and test_stem are called
2024        // Then: the normalized stems are extracted correctly
2025        assert_eq!(
2026            production_stem("src/users.service.ts"),
2027            Some("users.service")
2028        );
2029        assert_eq!(production_stem("src/App.tsx"), Some("App"));
2030        assert_eq!(
2031            test_stem("src/users.service.spec.ts"),
2032            Some("users.service")
2033        );
2034        assert_eq!(test_stem("src/utils.test.ts"), Some("utils"));
2035        assert_eq!(test_stem("src/App.test.tsx"), Some("App"));
2036        assert_eq!(test_stem("src/invalid.ts"), None);
2037    }
2038
2039    // === extract_imports Tests (IM1-IM7) ===
2040
2041    // IM1: named import の symbol と specifier が抽出される
2042    #[test]
2043    fn im1_named_import_symbol_and_specifier() {
2044        // Given: import_named.ts with `import { UsersController } from './users.controller'`
2045        let source = fixture("import_named.ts");
2046        let extractor = TypeScriptExtractor::new();
2047
2048        // When: extract_imports
2049        let imports = extractor.extract_imports(&source, "import_named.ts");
2050
2051        // Then: symbol: "UsersController", specifier: "./users.controller"
2052        let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2053        assert!(
2054            found.is_some(),
2055            "expected UsersController in imports: {imports:?}"
2056        );
2057        assert_eq!(
2058            found.unwrap().module_specifier,
2059            "./users.controller",
2060            "wrong specifier"
2061        );
2062    }
2063
2064    // IM2: 複数 named import (`{ A, B }`) が 2件返る (同specifier、異なるsymbol)
2065    #[test]
2066    fn im2_multiple_named_imports() {
2067        // Given: import_mixed.ts with `import { A, B } from './module'`
2068        let source = fixture("import_mixed.ts");
2069        let extractor = TypeScriptExtractor::new();
2070
2071        // When: extract_imports
2072        let imports = extractor.extract_imports(&source, "import_mixed.ts");
2073
2074        // Then: A と B が両方返る (同じ ./module specifier)
2075        let from_module: Vec<&ImportMapping> = imports
2076            .iter()
2077            .filter(|i| i.module_specifier == "./module")
2078            .collect();
2079        let symbols: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
2080        assert!(symbols.contains(&"A"), "expected A in symbols: {symbols:?}");
2081        assert!(symbols.contains(&"B"), "expected B in symbols: {symbols:?}");
2082        // at least 2 from ./module (IM2: { A, B } + IM3: { A as B } both in import_mixed.ts)
2083        assert!(
2084            from_module.len() >= 2,
2085            "expected at least 2 imports from ./module, got {from_module:?}"
2086        );
2087    }
2088
2089    // IM3: エイリアス import (`{ A as B }`) で元の名前 "A" が返る
2090    #[test]
2091    fn im3_alias_import_original_name() {
2092        // Given: import_mixed.ts with `import { A as B } from './module'`
2093        let source = fixture("import_mixed.ts");
2094        let extractor = TypeScriptExtractor::new();
2095
2096        // When: extract_imports
2097        let imports = extractor.extract_imports(&source, "import_mixed.ts");
2098
2099        // Then: symbol_name は "A" (エイリアス "B" ではなく元の名前)
2100        // import_mixed.ts has: { A, B } and { A as B } — both should yield A
2101        let a_count = imports.iter().filter(|i| i.symbol_name == "A").count();
2102        assert!(
2103            a_count >= 1,
2104            "expected at least one import with symbol_name 'A', got: {imports:?}"
2105        );
2106    }
2107
2108    // IM4: default import の symbol と specifier が抽出される
2109    #[test]
2110    fn im4_default_import() {
2111        // Given: import_default.ts with `import UsersController from './users.controller'`
2112        let source = fixture("import_default.ts");
2113        let extractor = TypeScriptExtractor::new();
2114
2115        // When: extract_imports
2116        let imports = extractor.extract_imports(&source, "import_default.ts");
2117
2118        // Then: symbol: "UsersController", specifier: "./users.controller"
2119        assert_eq!(imports.len(), 1, "expected 1 import, got {imports:?}");
2120        assert_eq!(imports[0].symbol_name, "UsersController");
2121        assert_eq!(imports[0].module_specifier, "./users.controller");
2122    }
2123
2124    // IM5: npm パッケージ import (`@nestjs/testing`) が除外される (空Vec)
2125    #[test]
2126    fn im5_npm_package_excluded() {
2127        // Given: source with only `import { Test } from '@nestjs/testing'`
2128        let source = "import { Test } from '@nestjs/testing';";
2129        let extractor = TypeScriptExtractor::new();
2130
2131        // When: extract_imports
2132        let imports = extractor.extract_imports(source, "test.ts");
2133
2134        // Then: 空Vec (npm パッケージは除外)
2135        assert!(imports.is_empty(), "expected empty vec, got {imports:?}");
2136    }
2137
2138    // IM6: 相対 `../` パスが含まれる
2139    #[test]
2140    fn im6_relative_parent_path() {
2141        // Given: import_named.ts with `import { S } from '../services/s.service'`
2142        let source = fixture("import_named.ts");
2143        let extractor = TypeScriptExtractor::new();
2144
2145        // When: extract_imports
2146        let imports = extractor.extract_imports(&source, "import_named.ts");
2147
2148        // Then: specifier: "../services/s.service"
2149        let found = imports
2150            .iter()
2151            .find(|i| i.module_specifier == "../services/s.service");
2152        assert!(
2153            found.is_some(),
2154            "expected ../services/s.service in imports: {imports:?}"
2155        );
2156        assert_eq!(found.unwrap().symbol_name, "S");
2157    }
2158
2159    // IM7: 空ソースで空Vec が返る
2160    #[test]
2161    fn im7_empty_source_returns_empty() {
2162        // Given: empty source
2163        let extractor = TypeScriptExtractor::new();
2164
2165        // When: extract_imports
2166        let imports = extractor.extract_imports("", "empty.ts");
2167
2168        // Then: 空Vec
2169        assert!(imports.is_empty());
2170    }
2171
2172    // IM8: namespace import (`import * as X from './module'`) が抽出される
2173    #[test]
2174    fn im8_namespace_import() {
2175        // Given: import_namespace.ts with `import * as UsersController from './users.controller'`
2176        let source = fixture("import_namespace.ts");
2177        let extractor = TypeScriptExtractor::new();
2178
2179        // When: extract_imports
2180        let imports = extractor.extract_imports(&source, "import_namespace.ts");
2181
2182        // Then: UsersController が symbol_name として抽出される
2183        let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2184        assert!(
2185            found.is_some(),
2186            "expected UsersController in imports: {imports:?}"
2187        );
2188        assert_eq!(found.unwrap().module_specifier, "./users.controller");
2189
2190        // Then: helpers も相対パスなので抽出される
2191        let helpers = imports.iter().find(|i| i.symbol_name == "helpers");
2192        assert!(
2193            helpers.is_some(),
2194            "expected helpers in imports: {imports:?}"
2195        );
2196        assert_eq!(helpers.unwrap().module_specifier, "../utils/helpers");
2197
2198        // Then: npm パッケージ (express) は除外される
2199        let express = imports.iter().find(|i| i.symbol_name == "express");
2200        assert!(
2201            express.is_none(),
2202            "npm package should be excluded: {imports:?}"
2203        );
2204    }
2205
2206    // IM9: type-only import (`import type { X }`) が除外され、通常importは残る
2207    #[test]
2208    fn im9_type_only_import_excluded() {
2209        // Given: import_type_only.ts with type-only and normal imports
2210        let source = fixture("import_type_only.ts");
2211        let extractor = TypeScriptExtractor::new();
2212
2213        // When: extract_imports
2214        let imports = extractor.extract_imports(&source, "import_type_only.ts");
2215
2216        // Then: `import type { UserService }` は除外される
2217        let user_service = imports.iter().find(|i| i.symbol_name == "UserService");
2218        assert!(
2219            user_service.is_none(),
2220            "type-only import should be excluded: {imports:?}"
2221        );
2222
2223        // Then: `import { type CreateUserDto }` (inline type modifier) も除外される
2224        let create_dto = imports.iter().find(|i| i.symbol_name == "CreateUserDto");
2225        assert!(
2226            create_dto.is_none(),
2227            "inline type modifier import should be excluded: {imports:?}"
2228        );
2229
2230        // Then: `import { UsersController }` は残る
2231        let controller = imports.iter().find(|i| i.symbol_name == "UsersController");
2232        assert!(
2233            controller.is_some(),
2234            "normal import should remain: {imports:?}"
2235        );
2236        assert_eq!(controller.unwrap().module_specifier, "./users.controller");
2237    }
2238
2239    // === resolve_import_path Tests (RP1-RP5) ===
2240
2241    // RP1: 拡張子なし specifier + 実在 `.ts` ファイル → Some(canonical path)
2242    #[test]
2243    fn rp1_resolve_ts_without_extension() {
2244        use std::io::Write as IoWrite;
2245        use tempfile::TempDir;
2246
2247        // Given: scan_root/src/users.controller.ts が実在する
2248        let dir = TempDir::new().unwrap();
2249        let src_dir = dir.path().join("src");
2250        std::fs::create_dir_all(&src_dir).unwrap();
2251        let target = src_dir.join("users.controller.ts");
2252        std::fs::File::create(&target).unwrap();
2253
2254        let from_file = src_dir.join("users.controller.spec.ts");
2255
2256        // When: resolve_import_path("./users.controller", ...)
2257        let result = resolve_import_path("./users.controller", &from_file, dir.path());
2258
2259        // Then: Some(canonical path)
2260        assert!(
2261            result.is_some(),
2262            "expected Some for existing .ts file, got None"
2263        );
2264        let resolved = result.unwrap();
2265        assert!(
2266            resolved.ends_with("users.controller.ts"),
2267            "expected path ending with users.controller.ts, got {resolved}"
2268        );
2269    }
2270
2271    // RP2: 拡張子付き specifier (`.ts`) + 実在ファイル → Some(canonical path)
2272    #[test]
2273    fn rp2_resolve_ts_with_extension() {
2274        use tempfile::TempDir;
2275
2276        // Given: scan_root/src/users.controller.ts が実在する
2277        let dir = TempDir::new().unwrap();
2278        let src_dir = dir.path().join("src");
2279        std::fs::create_dir_all(&src_dir).unwrap();
2280        let target = src_dir.join("users.controller.ts");
2281        std::fs::File::create(&target).unwrap();
2282
2283        let from_file = src_dir.join("users.controller.spec.ts");
2284
2285        // When: resolve_import_path("./users.controller.ts", ...) (拡張子付き)
2286        let result = resolve_import_path("./users.controller.ts", &from_file, dir.path());
2287
2288        // Then: Some(canonical path)
2289        assert!(
2290            result.is_some(),
2291            "expected Some for existing file with explicit .ts extension"
2292        );
2293    }
2294
2295    // RP3: 存在しないファイル → None
2296    #[test]
2297    fn rp3_nonexistent_file_returns_none() {
2298        use tempfile::TempDir;
2299
2300        // Given: scan_root が空
2301        let dir = TempDir::new().unwrap();
2302        let src_dir = dir.path().join("src");
2303        std::fs::create_dir_all(&src_dir).unwrap();
2304        let from_file = src_dir.join("some.spec.ts");
2305
2306        // When: resolve_import_path("./nonexistent", ...)
2307        let result = resolve_import_path("./nonexistent", &from_file, dir.path());
2308
2309        // Then: None
2310        assert!(result.is_none(), "expected None for nonexistent file");
2311    }
2312
2313    // RP4: scan_root 外のパス (`../../outside`) → None
2314    #[test]
2315    fn rp4_outside_scan_root_returns_none() {
2316        use tempfile::TempDir;
2317
2318        // Given: scan_root/src/ から ../../outside を参照 (scan_root 外)
2319        let dir = TempDir::new().unwrap();
2320        let src_dir = dir.path().join("src");
2321        std::fs::create_dir_all(&src_dir).unwrap();
2322        let from_file = src_dir.join("some.spec.ts");
2323
2324        // When: resolve_import_path("../../outside", ...)
2325        let result = resolve_import_path("../../outside", &from_file, dir.path());
2326
2327        // Then: None (path traversal ガード)
2328        assert!(result.is_none(), "expected None for path outside scan_root");
2329    }
2330
2331    // RP5: 拡張子なし specifier + 実在 `.tsx` ファイル → Some(canonical path)
2332    #[test]
2333    fn rp5_resolve_tsx_without_extension() {
2334        use tempfile::TempDir;
2335
2336        // Given: scan_root/src/App.tsx が実在する
2337        let dir = TempDir::new().unwrap();
2338        let src_dir = dir.path().join("src");
2339        std::fs::create_dir_all(&src_dir).unwrap();
2340        let target = src_dir.join("App.tsx");
2341        std::fs::File::create(&target).unwrap();
2342
2343        let from_file = src_dir.join("App.test.tsx");
2344
2345        // When: resolve_import_path("./App", ...)
2346        let result = resolve_import_path("./App", &from_file, dir.path());
2347
2348        // Then: Some(canonical path ending in App.tsx)
2349        assert!(
2350            result.is_some(),
2351            "expected Some for existing .tsx file, got None"
2352        );
2353        let resolved = result.unwrap();
2354        assert!(
2355            resolved.ends_with("App.tsx"),
2356            "expected path ending with App.tsx, got {resolved}"
2357        );
2358    }
2359
2360    // === map_test_files_with_imports Tests (MT1-MT4) ===
2361
2362    // MT1: Layer 1 マッチ + Layer 2 マッチが共存 → 両方マッピングされる
2363    #[test]
2364    fn mt1_layer1_and_layer2_both_matched() {
2365        use tempfile::TempDir;
2366
2367        // Given:
2368        //   production: src/users.controller.ts
2369        //   test (Layer 1 match): src/users.controller.spec.ts (same dir)
2370        //   test (Layer 2 match): test/users.controller.spec.ts (imports users.controller)
2371        let dir = TempDir::new().unwrap();
2372        let src_dir = dir.path().join("src");
2373        let test_dir = dir.path().join("test");
2374        std::fs::create_dir_all(&src_dir).unwrap();
2375        std::fs::create_dir_all(&test_dir).unwrap();
2376
2377        let prod_path = src_dir.join("users.controller.ts");
2378        std::fs::File::create(&prod_path).unwrap();
2379
2380        let layer1_test = src_dir.join("users.controller.spec.ts");
2381        let layer1_source = r#"// Layer 1 spec
2382describe('UsersController', () => {});
2383"#;
2384
2385        let layer2_test = test_dir.join("users.controller.spec.ts");
2386        let layer2_source = format!(
2387            "import {{ UsersController }} from '../src/users.controller';\ndescribe('cross', () => {{}});\n"
2388        );
2389
2390        let production_files = vec![prod_path.to_string_lossy().into_owned()];
2391        let mut test_sources = HashMap::new();
2392        test_sources.insert(
2393            layer1_test.to_string_lossy().into_owned(),
2394            layer1_source.to_string(),
2395        );
2396        test_sources.insert(
2397            layer2_test.to_string_lossy().into_owned(),
2398            layer2_source.to_string(),
2399        );
2400
2401        let extractor = TypeScriptExtractor::new();
2402
2403        // When: map_test_files_with_imports
2404        let mappings = extractor.map_test_files_with_imports(
2405            &production_files,
2406            &test_sources,
2407            dir.path(),
2408            false,
2409        );
2410
2411        // Then: 両方のテストがマッピングされる
2412        assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2413        let mapping = &mappings[0];
2414        assert!(
2415            mapping
2416                .test_files
2417                .contains(&layer1_test.to_string_lossy().into_owned()),
2418            "expected Layer 1 test in mapping, got {:?}",
2419            mapping.test_files
2420        );
2421        assert!(
2422            mapping
2423                .test_files
2424                .contains(&layer2_test.to_string_lossy().into_owned()),
2425            "expected Layer 2 test in mapping, got {:?}",
2426            mapping.test_files
2427        );
2428    }
2429
2430    // MT2: クロスディレクトリ import → ImportTracing でマッチ
2431    #[test]
2432    fn mt2_cross_directory_import_tracing() {
2433        use tempfile::TempDir;
2434
2435        // Given:
2436        //   production: src/services/user.service.ts
2437        //   test: test/user.service.spec.ts (imports user.service from cross-directory)
2438        //   Layer 1 は別ディレクトリのためマッチしない
2439        let dir = TempDir::new().unwrap();
2440        let src_dir = dir.path().join("src").join("services");
2441        let test_dir = dir.path().join("test");
2442        std::fs::create_dir_all(&src_dir).unwrap();
2443        std::fs::create_dir_all(&test_dir).unwrap();
2444
2445        let prod_path = src_dir.join("user.service.ts");
2446        std::fs::File::create(&prod_path).unwrap();
2447
2448        let test_path = test_dir.join("user.service.spec.ts");
2449        let test_source = format!(
2450            "import {{ UserService }} from '../src/services/user.service';\ndescribe('cross', () => {{}});\n"
2451        );
2452
2453        let production_files = vec![prod_path.to_string_lossy().into_owned()];
2454        let mut test_sources = HashMap::new();
2455        test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2456
2457        let extractor = TypeScriptExtractor::new();
2458
2459        // When: map_test_files_with_imports
2460        let mappings = extractor.map_test_files_with_imports(
2461            &production_files,
2462            &test_sources,
2463            dir.path(),
2464            false,
2465        );
2466
2467        // Then: ImportTracing でマッチ
2468        assert_eq!(mappings.len(), 1);
2469        let mapping = &mappings[0];
2470        assert!(
2471            mapping
2472                .test_files
2473                .contains(&test_path.to_string_lossy().into_owned()),
2474            "expected test in mapping via ImportTracing, got {:?}",
2475            mapping.test_files
2476        );
2477        assert_eq!(
2478            mapping.strategy,
2479            MappingStrategy::ImportTracing,
2480            "expected ImportTracing strategy"
2481        );
2482    }
2483
2484    // MT3: npm import のみ → 未マッチ
2485    #[test]
2486    fn mt3_npm_only_import_not_matched() {
2487        use tempfile::TempDir;
2488
2489        // Given:
2490        //   production: src/users.controller.ts
2491        //   test: test/something.spec.ts (imports only from npm)
2492        let dir = TempDir::new().unwrap();
2493        let src_dir = dir.path().join("src");
2494        let test_dir = dir.path().join("test");
2495        std::fs::create_dir_all(&src_dir).unwrap();
2496        std::fs::create_dir_all(&test_dir).unwrap();
2497
2498        let prod_path = src_dir.join("users.controller.ts");
2499        std::fs::File::create(&prod_path).unwrap();
2500
2501        let test_path = test_dir.join("something.spec.ts");
2502        let test_source =
2503            "import { Test } from '@nestjs/testing';\ndescribe('npm', () => {});\n".to_string();
2504
2505        let production_files = vec![prod_path.to_string_lossy().into_owned()];
2506        let mut test_sources = HashMap::new();
2507        test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2508
2509        let extractor = TypeScriptExtractor::new();
2510
2511        // When: map_test_files_with_imports
2512        let mappings = extractor.map_test_files_with_imports(
2513            &production_files,
2514            &test_sources,
2515            dir.path(),
2516            false,
2517        );
2518
2519        // Then: 未マッチ (test_files は空)
2520        assert_eq!(mappings.len(), 1);
2521        assert!(
2522            mappings[0].test_files.is_empty(),
2523            "expected no test files for npm-only import, got {:?}",
2524            mappings[0].test_files
2525        );
2526    }
2527
2528    // MT4: 1テストが複数 production を import → 両方にマッピング
2529    #[test]
2530    fn mt4_one_test_imports_multiple_productions() {
2531        use tempfile::TempDir;
2532
2533        // Given:
2534        //   production A: src/a.service.ts
2535        //   production B: src/b.service.ts
2536        //   test: test/ab.spec.ts (imports both A and B)
2537        let dir = TempDir::new().unwrap();
2538        let src_dir = dir.path().join("src");
2539        let test_dir = dir.path().join("test");
2540        std::fs::create_dir_all(&src_dir).unwrap();
2541        std::fs::create_dir_all(&test_dir).unwrap();
2542
2543        let prod_a = src_dir.join("a.service.ts");
2544        let prod_b = src_dir.join("b.service.ts");
2545        std::fs::File::create(&prod_a).unwrap();
2546        std::fs::File::create(&prod_b).unwrap();
2547
2548        let test_path = test_dir.join("ab.spec.ts");
2549        let test_source = format!(
2550            "import {{ A }} from '../src/a.service';\nimport {{ B }} from '../src/b.service';\ndescribe('ab', () => {{}});\n"
2551        );
2552
2553        let production_files = vec![
2554            prod_a.to_string_lossy().into_owned(),
2555            prod_b.to_string_lossy().into_owned(),
2556        ];
2557        let mut test_sources = HashMap::new();
2558        test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2559
2560        let extractor = TypeScriptExtractor::new();
2561
2562        // When: map_test_files_with_imports
2563        let mappings = extractor.map_test_files_with_imports(
2564            &production_files,
2565            &test_sources,
2566            dir.path(),
2567            false,
2568        );
2569
2570        // Then: A と B 両方に test がマッピングされる
2571        assert_eq!(mappings.len(), 2, "expected 2 FileMappings (A and B)");
2572        for mapping in &mappings {
2573            assert!(
2574                mapping
2575                    .test_files
2576                    .contains(&test_path.to_string_lossy().into_owned()),
2577                "expected ab.spec.ts mapped to {}, got {:?}",
2578                mapping.production_file,
2579                mapping.test_files
2580            );
2581        }
2582    }
2583
2584    // HELPER-01: constants.ts is detected as non-SUT helper
2585    #[test]
2586    fn is_non_sut_helper_constants_ts() {
2587        assert!(is_non_sut_helper("src/constants.ts", false));
2588    }
2589
2590    // HELPER-02: index.ts is detected as non-SUT helper
2591    #[test]
2592    fn is_non_sut_helper_index_ts() {
2593        assert!(is_non_sut_helper("src/index.ts", false));
2594    }
2595
2596    // HELPER-03: extension variants (.js/.tsx/.jsx) are also detected
2597    #[test]
2598    fn is_non_sut_helper_extension_variants() {
2599        assert!(is_non_sut_helper("src/constants.js", false));
2600        assert!(is_non_sut_helper("src/constants.tsx", false));
2601        assert!(is_non_sut_helper("src/constants.jsx", false));
2602        assert!(is_non_sut_helper("src/index.js", false));
2603        assert!(is_non_sut_helper("src/index.tsx", false));
2604        assert!(is_non_sut_helper("src/index.jsx", false));
2605    }
2606
2607    // HELPER-04: similar but distinct filenames are NOT helpers
2608    #[test]
2609    fn is_non_sut_helper_rejects_non_helpers() {
2610        assert!(!is_non_sut_helper("src/my-constants.ts", false));
2611        assert!(!is_non_sut_helper("src/service.ts", false));
2612        assert!(!is_non_sut_helper("src/app.constants.ts", false));
2613        assert!(!is_non_sut_helper("src/constants-v2.ts", false));
2614    }
2615
2616    // HELPER-05: directory named constants/app.ts is NOT a helper
2617    #[test]
2618    fn is_non_sut_helper_rejects_directory_name() {
2619        assert!(!is_non_sut_helper("constants/app.ts", false));
2620        assert!(!is_non_sut_helper("index/service.ts", false));
2621    }
2622
2623    // HELPER-06: *.enum.ts is detected as non-SUT helper
2624    #[test]
2625    fn is_non_sut_helper_enum_ts() {
2626        // Given: a file with .enum.ts suffix
2627        let path = "src/enums/request-method.enum.ts";
2628        // When: is_non_sut_helper() is called
2629        // Then: returns true
2630        assert!(is_non_sut_helper(path, false));
2631    }
2632
2633    // HELPER-07: *.interface.ts is detected as non-SUT helper
2634    #[test]
2635    fn is_non_sut_helper_interface_ts() {
2636        // Given: a file with .interface.ts suffix
2637        let path = "src/interfaces/middleware-configuration.interface.ts";
2638        // When: is_non_sut_helper() is called
2639        // Then: returns true
2640        assert!(is_non_sut_helper(path, false));
2641    }
2642
2643    // HELPER-08: *.exception.ts is detected as non-SUT helper
2644    #[test]
2645    fn is_non_sut_helper_exception_ts() {
2646        // Given: a file with .exception.ts suffix
2647        let path = "src/errors/unknown-module.exception.ts";
2648        // When: is_non_sut_helper() is called
2649        // Then: returns true
2650        assert!(is_non_sut_helper(path, false));
2651    }
2652
2653    // HELPER-09: file inside a test path is detected as non-SUT helper
2654    #[test]
2655    fn is_non_sut_helper_test_path() {
2656        // Given: a file located under a /test/ directory
2657        let path = "packages/core/test/utils/string.cleaner.ts";
2658        // When: is_non_sut_helper() is called
2659        // Then: returns true
2660        assert!(is_non_sut_helper(path, false));
2661        // __tests__ variant
2662        assert!(is_non_sut_helper(
2663            "packages/core/__tests__/utils/helper.ts",
2664            false
2665        ));
2666        // segment-based: "contest" should NOT match
2667        assert!(!is_non_sut_helper(
2668            "/home/user/projects/contest/src/service.ts",
2669            false
2670        ));
2671        assert!(!is_non_sut_helper("src/latest/foo.ts", false));
2672    }
2673
2674    // HELPER-10: suffix-like but plain filename (not a suffix) is rejected
2675    #[test]
2676    fn is_non_sut_helper_rejects_plain_filename() {
2677        // Given: files whose name is exactly enum.ts / interface.ts / exception.ts
2678        // (the type keyword is the entire filename, not a suffix)
2679        // When: is_non_sut_helper() is called
2680        // Then: returns false (these may be real SUT files)
2681        assert!(!is_non_sut_helper("src/enum.ts", false));
2682        assert!(!is_non_sut_helper("src/interface.ts", false));
2683        assert!(!is_non_sut_helper("src/exception.ts", false));
2684    }
2685
2686    // HELPER-11: extension variants (.js/.tsx/.jsx) with enum/interface suffix are detected
2687    #[test]
2688    fn is_non_sut_helper_enum_interface_extension_variants() {
2689        // Given: files with .enum or .interface suffix and non-.ts extension
2690        // When: is_non_sut_helper() is called
2691        // Then: returns true
2692        assert!(is_non_sut_helper("src/foo.enum.js", false));
2693        assert!(is_non_sut_helper("src/bar.interface.tsx", false));
2694    }
2695
2696    // === is_type_definition_file unit tests (TD-01 ~ TD-05) ===
2697
2698    // TD-01: *.enum.ts is a type definition file
2699    #[test]
2700    fn is_type_definition_file_enum() {
2701        assert!(is_type_definition_file("src/foo.enum.ts"));
2702    }
2703
2704    // TD-02: *.interface.ts is a type definition file
2705    #[test]
2706    fn is_type_definition_file_interface() {
2707        assert!(is_type_definition_file("src/bar.interface.ts"));
2708    }
2709
2710    // TD-03: *.exception.ts is a type definition file
2711    #[test]
2712    fn is_type_definition_file_exception() {
2713        assert!(is_type_definition_file("src/baz.exception.ts"));
2714    }
2715
2716    // TD-04: regular service file is NOT a type definition file
2717    #[test]
2718    fn is_type_definition_file_service() {
2719        assert!(!is_type_definition_file("src/service.ts"));
2720    }
2721
2722    // TD-05: constants.ts is NOT a type definition file (suffix check only, not exact-match)
2723    #[test]
2724    fn is_type_definition_file_constants() {
2725        // constants.ts has no .enum/.interface/.exception suffix
2726        assert!(!is_type_definition_file("src/constants.ts"));
2727    }
2728
2729    // === is_non_sut_helper (production-aware) unit tests (PA-01 ~ PA-03) ===
2730
2731    // PA-01: enum file with known_production=true bypasses suffix filter
2732    #[test]
2733    fn is_non_sut_helper_production_enum_bypassed() {
2734        // Given: an enum file known to be in production_files
2735        // When: is_non_sut_helper with is_known_production=true
2736        // Then: returns false (not filtered)
2737        assert!(!is_non_sut_helper("src/foo.enum.ts", true));
2738    }
2739
2740    // PA-02: enum file with known_production=false is still filtered
2741    #[test]
2742    fn is_non_sut_helper_unknown_enum_filtered() {
2743        // Given: an enum file NOT in production_files
2744        // When: is_non_sut_helper with is_known_production=false
2745        // Then: returns true (filtered as before)
2746        assert!(is_non_sut_helper("src/foo.enum.ts", false));
2747    }
2748
2749    // PA-03: constants.ts is filtered regardless of known_production
2750    #[test]
2751    fn is_non_sut_helper_constants_always_filtered() {
2752        // Given: constants.ts (exact-match filter, not suffix)
2753        // When: is_non_sut_helper with is_known_production=true
2754        // Then: returns true (exact-match is independent of production status)
2755        assert!(is_non_sut_helper("src/constants.ts", true));
2756    }
2757
2758    // === Barrel Import Resolution Tests (BARREL-01 ~ BARREL-09) ===
2759
2760    // BARREL-01: resolve_import_path がディレクトリの index.ts にフォールバックする
2761    #[test]
2762    fn barrel_01_resolve_directory_to_index_ts() {
2763        use tempfile::TempDir;
2764
2765        // Given: scan_root/decorators/index.ts が存在
2766        let dir = TempDir::new().unwrap();
2767        let decorators_dir = dir.path().join("decorators");
2768        std::fs::create_dir_all(&decorators_dir).unwrap();
2769        std::fs::File::create(decorators_dir.join("index.ts")).unwrap();
2770
2771        // from_file は scan_root/src/some.spec.ts (../../decorators → decorators/)
2772        let src_dir = dir.path().join("src");
2773        std::fs::create_dir_all(&src_dir).unwrap();
2774        let from_file = src_dir.join("some.spec.ts");
2775
2776        // When: resolve_import_path("../decorators", from_file, scan_root)
2777        let result = resolve_import_path("../decorators", &from_file, dir.path());
2778
2779        // Then: decorators/index.ts のパスを返す
2780        assert!(
2781            result.is_some(),
2782            "expected Some for directory with index.ts, got None"
2783        );
2784        let resolved = result.unwrap();
2785        assert!(
2786            resolved.ends_with("decorators/index.ts"),
2787            "expected path ending with decorators/index.ts, got {resolved}"
2788        );
2789    }
2790
2791    // BARREL-02: extract_barrel_re_exports が named re-export をキャプチャする
2792    #[test]
2793    fn barrel_02_re_export_named_capture() {
2794        // Given: `export { Foo } from './foo'`
2795        let source = "export { Foo } from './foo';";
2796        let extractor = TypeScriptExtractor::new();
2797
2798        // When: extract_barrel_re_exports
2799        let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2800
2801        // Then: symbols=["Foo"], from="./foo", wildcard=false
2802        assert_eq!(
2803            re_exports.len(),
2804            1,
2805            "expected 1 re-export, got {re_exports:?}"
2806        );
2807        let re = &re_exports[0];
2808        assert_eq!(re.symbols, vec!["Foo".to_string()]);
2809        assert_eq!(re.from_specifier, "./foo");
2810        assert!(!re.wildcard);
2811    }
2812
2813    // BARREL-03: extract_barrel_re_exports が wildcard re-export をキャプチャする
2814    #[test]
2815    fn barrel_03_re_export_wildcard_capture() {
2816        // Given: `export * from './foo'`
2817        let source = "export * from './foo';";
2818        let extractor = TypeScriptExtractor::new();
2819
2820        // When: extract_barrel_re_exports
2821        let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2822
2823        // Then: wildcard=true, from="./foo"
2824        assert_eq!(
2825            re_exports.len(),
2826            1,
2827            "expected 1 re-export, got {re_exports:?}"
2828        );
2829        let re = &re_exports[0];
2830        assert!(re.wildcard, "expected wildcard=true");
2831        assert_eq!(re.from_specifier, "./foo");
2832    }
2833
2834    // BARREL-04: resolve_barrel_exports が 1ホップのバレルを解決する
2835    #[test]
2836    fn barrel_04_resolve_barrel_exports_one_hop() {
2837        use tempfile::TempDir;
2838
2839        // Given:
2840        //   index.ts: export { Foo } from './foo'
2841        //   foo.ts: (実在)
2842        let dir = TempDir::new().unwrap();
2843        let index_path = dir.path().join("index.ts");
2844        std::fs::write(&index_path, "export { Foo } from './foo';").unwrap();
2845        let foo_path = dir.path().join("foo.ts");
2846        std::fs::File::create(&foo_path).unwrap();
2847
2848        // When: resolve_barrel_exports(index_path, ["Foo"], scan_root)
2849        let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2850
2851        // Then: [foo.ts] を返す
2852        assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2853        assert!(
2854            result[0].ends_with("foo.ts"),
2855            "expected foo.ts, got {:?}",
2856            result[0]
2857        );
2858    }
2859
2860    // BARREL-05: resolve_barrel_exports が 2ホップのバレルを解決する
2861    #[test]
2862    fn barrel_05_resolve_barrel_exports_two_hops() {
2863        use tempfile::TempDir;
2864
2865        // Given:
2866        //   index.ts: export * from './core'
2867        //   core/index.ts: export { Foo } from './foo'
2868        //   core/foo.ts: (実在)
2869        let dir = TempDir::new().unwrap();
2870        let index_path = dir.path().join("index.ts");
2871        std::fs::write(&index_path, "export * from './core';").unwrap();
2872
2873        let core_dir = dir.path().join("core");
2874        std::fs::create_dir_all(&core_dir).unwrap();
2875        std::fs::write(core_dir.join("index.ts"), "export { Foo } from './foo';").unwrap();
2876        let foo_path = core_dir.join("foo.ts");
2877        std::fs::File::create(&foo_path).unwrap();
2878
2879        // When: resolve_barrel_exports(index_path, ["Foo"], scan_root)
2880        let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2881
2882        // Then: core/foo.ts を返す
2883        assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2884        assert!(
2885            result[0].ends_with("foo.ts"),
2886            "expected foo.ts, got {:?}",
2887            result[0]
2888        );
2889    }
2890
2891    // BARREL-06: 循環バレルで無限ループしない
2892    #[test]
2893    fn barrel_06_circular_barrel_no_infinite_loop() {
2894        use tempfile::TempDir;
2895
2896        // Given:
2897        //   a/index.ts: export * from '../b'
2898        //   b/index.ts: export * from '../a'
2899        let dir = TempDir::new().unwrap();
2900        let a_dir = dir.path().join("a");
2901        let b_dir = dir.path().join("b");
2902        std::fs::create_dir_all(&a_dir).unwrap();
2903        std::fs::create_dir_all(&b_dir).unwrap();
2904        std::fs::write(a_dir.join("index.ts"), "export * from '../b';").unwrap();
2905        std::fs::write(b_dir.join("index.ts"), "export * from '../a';").unwrap();
2906
2907        let a_index = a_dir.join("index.ts");
2908
2909        // When: resolve_barrel_exports — must NOT panic or hang
2910        let result = resolve_barrel_exports(&a_index, &["Foo".to_string()], dir.path());
2911
2912        // Then: 空結果を返し、パニックしない
2913        assert!(
2914            result.is_empty(),
2915            "expected empty result for circular barrel, got {result:?}"
2916        );
2917    }
2918
2919    // BARREL-07: Layer 2 で barrel 経由の import が production file にマッチする
2920    #[test]
2921    fn barrel_07_layer2_barrel_import_matches_production() {
2922        use tempfile::TempDir;
2923
2924        // Given:
2925        //   production: src/foo.service.ts
2926        //   barrel: src/decorators/index.ts — export { Foo } from './foo.service'
2927        //           ただし src/decorators/foo.service.ts として re-export 先を指す
2928        //   test: test/foo.spec.ts — import { Foo } from '../src/decorators'
2929        let dir = TempDir::new().unwrap();
2930        let src_dir = dir.path().join("src");
2931        let decorators_dir = src_dir.join("decorators");
2932        let test_dir = dir.path().join("test");
2933        std::fs::create_dir_all(&decorators_dir).unwrap();
2934        std::fs::create_dir_all(&test_dir).unwrap();
2935
2936        // Production file
2937        let prod_path = src_dir.join("foo.service.ts");
2938        std::fs::File::create(&prod_path).unwrap();
2939
2940        // Barrel: decorators/index.ts re-exports from ../foo.service
2941        std::fs::write(
2942            decorators_dir.join("index.ts"),
2943            "export { Foo } from '../foo.service';",
2944        )
2945        .unwrap();
2946
2947        // Test imports from barrel directory
2948        let test_path = test_dir.join("foo.spec.ts");
2949        std::fs::write(
2950            &test_path,
2951            "import { Foo } from '../src/decorators';\ndescribe('foo', () => {});",
2952        )
2953        .unwrap();
2954
2955        let production_files = vec![prod_path.to_string_lossy().into_owned()];
2956        let mut test_sources = HashMap::new();
2957        test_sources.insert(
2958            test_path.to_string_lossy().into_owned(),
2959            std::fs::read_to_string(&test_path).unwrap(),
2960        );
2961
2962        let extractor = TypeScriptExtractor::new();
2963
2964        // When: map_test_files_with_imports (barrel resolution enabled)
2965        let mappings = extractor.map_test_files_with_imports(
2966            &production_files,
2967            &test_sources,
2968            dir.path(),
2969            false,
2970        );
2971
2972        // Then: foo.service.ts に foo.spec.ts がマッピングされる
2973        assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2974        assert!(
2975            mappings[0]
2976                .test_files
2977                .contains(&test_path.to_string_lossy().into_owned()),
2978            "expected foo.spec.ts mapped via barrel, got {:?}",
2979            mappings[0].test_files
2980        );
2981    }
2982
2983    // BARREL-08: is_non_sut_helper フィルタが barrel 解決後のファイルに適用される
2984    #[test]
2985    fn barrel_08_non_sut_filter_applied_after_barrel_resolution() {
2986        use tempfile::TempDir;
2987
2988        // Given:
2989        //   barrel: index.ts → export { SOME_CONST } from './constants'
2990        //   resolved: constants.ts (is_non_sut_helper → true)
2991        //   test imports from barrel
2992        let dir = TempDir::new().unwrap();
2993        let src_dir = dir.path().join("src");
2994        let test_dir = dir.path().join("test");
2995        std::fs::create_dir_all(&src_dir).unwrap();
2996        std::fs::create_dir_all(&test_dir).unwrap();
2997
2998        // Production file (real SUT)
2999        let prod_path = src_dir.join("user.service.ts");
3000        std::fs::File::create(&prod_path).unwrap();
3001
3002        // Barrel index: re-exports from constants
3003        std::fs::write(
3004            src_dir.join("index.ts"),
3005            "export { SOME_CONST } from './constants';",
3006        )
3007        .unwrap();
3008        // constants.ts (non-SUT helper)
3009        std::fs::File::create(src_dir.join("constants.ts")).unwrap();
3010
3011        // Test imports from barrel (which resolves to constants.ts)
3012        let test_path = test_dir.join("barrel_const.spec.ts");
3013        std::fs::write(
3014            &test_path,
3015            "import { SOME_CONST } from '../src';\ndescribe('const', () => {});",
3016        )
3017        .unwrap();
3018
3019        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3020        let mut test_sources = HashMap::new();
3021        test_sources.insert(
3022            test_path.to_string_lossy().into_owned(),
3023            std::fs::read_to_string(&test_path).unwrap(),
3024        );
3025
3026        let extractor = TypeScriptExtractor::new();
3027
3028        // When: map_test_files_with_imports
3029        let mappings = extractor.map_test_files_with_imports(
3030            &production_files,
3031            &test_sources,
3032            dir.path(),
3033            false,
3034        );
3035
3036        // Then: user.service.ts にはマッピングされない (constants.ts はフィルタ済み)
3037        assert_eq!(
3038            mappings.len(),
3039            1,
3040            "expected 1 FileMapping for user.service.ts"
3041        );
3042        assert!(
3043            mappings[0].test_files.is_empty(),
3044            "constants.ts should be filtered out, but got {:?}",
3045            mappings[0].test_files
3046        );
3047    }
3048
3049    // BARREL-09: extract_imports が symbol 名を保持する (ImportMapping::symbols フィールド)
3050    #[test]
3051    fn barrel_09_extract_imports_retains_symbols() {
3052        // Given: `import { Foo, Bar } from './module'`
3053        let source = "import { Foo, Bar } from './module';";
3054        let extractor = TypeScriptExtractor::new();
3055
3056        // When: extract_imports
3057        let imports = extractor.extract_imports(source, "test.ts");
3058
3059        // Then: Foo と Bar の両方が symbols として存在する
3060        // ImportMapping は symbol_name を 1件ずつ返すが、
3061        // 同一 module_specifier からの import は symbols Vec に集約される
3062        let from_module: Vec<&ImportMapping> = imports
3063            .iter()
3064            .filter(|i| i.module_specifier == "./module")
3065            .collect();
3066        let names: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
3067        assert!(names.contains(&"Foo"), "expected Foo in symbols: {names:?}");
3068        assert!(names.contains(&"Bar"), "expected Bar in symbols: {names:?}");
3069
3070        // BARREL-09 の本質: ImportMapping に symbols フィールドが存在し、
3071        // 同じ specifier からの import が集約されること
3072        // (現在の ImportMapping は symbol_name: String のみ → symbols: Vec<String> への移行が必要)
3073        let grouped = imports
3074            .iter()
3075            .filter(|i| i.module_specifier == "./module")
3076            .fold(Vec::<String>::new(), |mut acc, i| {
3077                acc.push(i.symbol_name.clone());
3078                acc
3079            });
3080        // symbols フィールドが実装されたら、1つの ImportMapping に ["Foo", "Bar"] が入る想定
3081        // 現時点では 2件の ImportMapping として返されることを確認
3082        assert_eq!(
3083            grouped.len(),
3084            2,
3085            "expected 2 symbols from ./module, got {grouped:?}"
3086        );
3087
3088        // Verify symbols field aggregation: each ImportMapping from ./module
3089        // should have both Foo and Bar in its symbols Vec
3090        let first_import = imports
3091            .iter()
3092            .find(|i| i.module_specifier == "./module")
3093            .expect("expected at least one import from ./module");
3094        let symbols = &first_import.symbols;
3095        assert!(
3096            symbols.contains(&"Foo".to_string()),
3097            "symbols should contain Foo, got {symbols:?}"
3098        );
3099        assert!(
3100            symbols.contains(&"Bar".to_string()),
3101            "symbols should contain Bar, got {symbols:?}"
3102        );
3103        assert_eq!(
3104            symbols.len(),
3105            2,
3106            "expected exactly 2 symbols, got {symbols:?}"
3107        );
3108    }
3109
3110    // BARREL-10: wildcard-only barrel で symbol フィルタが効く
3111    // NestJS パターン: index.ts → export * from './core' → core/index.ts → export * from './foo'
3112    // テストが { Foo } のみ import → foo.ts のみマッチ、bar.ts はマッチしない
3113    #[test]
3114    fn barrel_10_wildcard_barrel_symbol_filter() {
3115        use tempfile::TempDir;
3116
3117        // Given:
3118        //   index.ts: export * from './core'
3119        //   core/index.ts: export * from './foo' + export * from './bar'
3120        //   core/foo.ts: export function Foo() {}
3121        //   core/bar.ts: export function Bar() {}
3122        let dir = TempDir::new().unwrap();
3123        let core_dir = dir.path().join("core");
3124        std::fs::create_dir_all(&core_dir).unwrap();
3125
3126        std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3127        std::fs::write(
3128            core_dir.join("index.ts"),
3129            "export * from './foo';\nexport * from './bar';",
3130        )
3131        .unwrap();
3132        std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3133        std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3134
3135        // When: resolve with symbols=["Foo"]
3136        let result = resolve_barrel_exports(
3137            &dir.path().join("index.ts"),
3138            &["Foo".to_string()],
3139            dir.path(),
3140        );
3141
3142        // Then: foo.ts のみ返す (bar.ts は Foo を export していないのでマッチしない)
3143        assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
3144        assert!(
3145            result[0].ends_with("foo.ts"),
3146            "expected foo.ts, got {:?}",
3147            result[0]
3148        );
3149    }
3150
3151    // BARREL-11: wildcard barrel + symbols empty → 全ファイルを返す (保守的)
3152    #[test]
3153    fn barrel_11_wildcard_barrel_empty_symbols_match_all() {
3154        use tempfile::TempDir;
3155
3156        let dir = TempDir::new().unwrap();
3157        let core_dir = dir.path().join("core");
3158        std::fs::create_dir_all(&core_dir).unwrap();
3159
3160        std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3161        std::fs::write(
3162            core_dir.join("index.ts"),
3163            "export * from './foo';\nexport * from './bar';",
3164        )
3165        .unwrap();
3166        std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3167        std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3168
3169        // When: resolve with empty symbols (match all)
3170        let result = resolve_barrel_exports(&dir.path().join("index.ts"), &[], dir.path());
3171
3172        // Then: both files returned
3173        assert_eq!(result.len(), 2, "expected 2 resolved files, got {result:?}");
3174    }
3175
3176    // === Boundary Specification Tests (B1-B6) ===
3177    // These tests document behavior at failure boundaries.
3178    // B1: Fixed — namespace re-export is now captured as wildcard.
3179    // B2-B6: Assertions reflect known limitations (not desired future behavior).
3180
3181    // TC-01: Boundary B1 — namespace re-export captured as wildcard=true (B1 fix applied)
3182    #[test]
3183    fn boundary_b1_ns_reexport_captured_as_wildcard() {
3184        // Given: barrel index.ts with `export * as Ns from './validators'`
3185        let source = "export * as Validators from './validators';";
3186        let extractor = TypeScriptExtractor::new();
3187
3188        // When: extract_barrel_re_exports
3189        let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
3190
3191        // Then: namespace re-export IS captured as wildcard=true (B1 fix applied)
3192        assert_eq!(
3193            re_exports.len(),
3194            1,
3195            "expected 1 re-export for namespace export, got {:?}",
3196            re_exports
3197        );
3198        let re = &re_exports[0];
3199        assert_eq!(re.from_specifier, "./validators");
3200        assert!(
3201            re.wildcard,
3202            "expected wildcard=true for namespace re-export, got {:?}",
3203            re
3204        );
3205    }
3206
3207    // TC-02: Boundary B1 — namespace re-export causes test-to-code mapping miss (FN)
3208    #[test]
3209    fn boundary_b1_ns_reexport_mapping_miss() {
3210        use tempfile::TempDir;
3211
3212        // Given:
3213        //   validators/foo.service.ts (production)
3214        //   index.ts: `export * as Validators from './validators'`
3215        //   validators/index.ts: `export { FooService } from './foo.service'`
3216        //   test/foo.spec.ts: `import { Validators } from '../index'`
3217        let dir = TempDir::new().unwrap();
3218        let validators_dir = dir.path().join("validators");
3219        let test_dir = dir.path().join("test");
3220        std::fs::create_dir_all(&validators_dir).unwrap();
3221        std::fs::create_dir_all(&test_dir).unwrap();
3222
3223        // Production file
3224        let prod_path = validators_dir.join("foo.service.ts");
3225        std::fs::File::create(&prod_path).unwrap();
3226
3227        // Root barrel: namespace re-export (B1 boundary)
3228        std::fs::write(
3229            dir.path().join("index.ts"),
3230            "export * as Validators from './validators';",
3231        )
3232        .unwrap();
3233
3234        // validators/index.ts: named re-export
3235        std::fs::write(
3236            validators_dir.join("index.ts"),
3237            "export { FooService } from './foo.service';",
3238        )
3239        .unwrap();
3240
3241        // Test imports via namespace re-export
3242        let test_path = test_dir.join("foo.spec.ts");
3243        std::fs::write(
3244            &test_path,
3245            "import { Validators } from '../index';\ndescribe('FooService', () => {});",
3246        )
3247        .unwrap();
3248
3249        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3250        let mut test_sources = HashMap::new();
3251        test_sources.insert(
3252            test_path.to_string_lossy().into_owned(),
3253            std::fs::read_to_string(&test_path).unwrap(),
3254        );
3255
3256        let extractor = TypeScriptExtractor::new();
3257
3258        // When: map_test_files_with_imports
3259        let mappings = extractor.map_test_files_with_imports(
3260            &production_files,
3261            &test_sources,
3262            dir.path(),
3263            false,
3264        );
3265
3266        // Then: foo.service.ts IS mapped to foo.spec.ts (B1 fix: namespace re-export resolved)
3267        let prod_mapping = mappings
3268            .iter()
3269            .find(|m| m.production_file.contains("foo.service.ts"));
3270        assert!(
3271            prod_mapping.is_some(),
3272            "expected foo.service.ts to appear in mappings, got {:?}",
3273            mappings
3274        );
3275        let mapping = prod_mapping.unwrap();
3276        assert!(
3277            !mapping.test_files.is_empty(),
3278            "expected foo.service.ts to have test_files (Layer 2 via namespace re-export), got {:?}",
3279            mapping
3280        );
3281    }
3282
3283    // TC-03: Boundary B2 — non-relative import is skipped by extract_imports
3284    #[test]
3285    fn boundary_b2_non_relative_import_skipped() {
3286        // Given: source with `import { Injectable } from '@nestjs/common'`
3287        let source = "import { Injectable } from '@nestjs/common';";
3288        let extractor = TypeScriptExtractor::new();
3289
3290        // When: extract_imports
3291        let imports = extractor.extract_imports(source, "app.service.ts");
3292
3293        // Then: imports is empty (non-relative paths are excluded)
3294        assert!(
3295            imports.is_empty(),
3296            "expected empty imports for non-relative path, got {:?}",
3297            imports
3298        );
3299    }
3300
3301    // TC-04: Boundary B2 — cross-package symlink import is resolved via node_modules symlink (TP)
3302    // Formerly "boundary_b2_cross_pkg_barrel_unresolvable" (was asserting FN; now asserts TP after B2 fix)
3303    #[cfg(unix)]
3304    #[test]
3305    fn boundary_b2_cross_pkg_symlink_resolved() {
3306        use std::os::unix::fs::symlink;
3307        use tempfile::TempDir;
3308
3309        // Given:
3310        //   packages/common/src/foo.ts (production, cross-package file)
3311        //   packages/core/ (scan_root)
3312        //   packages/core/src/foo.service.ts (local production)
3313        //   packages/core/node_modules/@org/common -> ../../common (symlink)
3314        //   packages/core/test/foo.spec.ts: `import { Foo } from '@org/common'`
3315        //   production_files contains BOTH foo.service.ts AND packages/common/src/foo.ts
3316        let dir = TempDir::new().unwrap();
3317        let core_src = dir.path().join("packages").join("core").join("src");
3318        let core_test = dir.path().join("packages").join("core").join("test");
3319        let core_nm_org = dir
3320            .path()
3321            .join("packages")
3322            .join("core")
3323            .join("node_modules")
3324            .join("@org");
3325        let common_src = dir.path().join("packages").join("common").join("src");
3326        std::fs::create_dir_all(&core_src).unwrap();
3327        std::fs::create_dir_all(&core_test).unwrap();
3328        std::fs::create_dir_all(&core_nm_org).unwrap();
3329        std::fs::create_dir_all(&common_src).unwrap();
3330
3331        let local_prod_path = core_src.join("foo.service.ts");
3332        std::fs::File::create(&local_prod_path).unwrap();
3333
3334        let common_path = common_src.join("foo.ts");
3335        std::fs::File::create(&common_path).unwrap();
3336
3337        // Create symlink: packages/core/node_modules/@org/common -> ../../common
3338        let symlink_path = core_nm_org.join("common");
3339        let target = dir.path().join("packages").join("common");
3340        symlink(&target, &symlink_path).unwrap();
3341
3342        let test_path = core_test.join("foo.spec.ts");
3343        std::fs::write(
3344            &test_path,
3345            "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3346        )
3347        .unwrap();
3348
3349        let scan_root = dir.path().join("packages").join("core");
3350        // production_files contains cross-package file (common/src/foo.ts)
3351        let production_files = vec![
3352            local_prod_path.to_string_lossy().into_owned(),
3353            common_path.to_string_lossy().into_owned(),
3354        ];
3355        let mut test_sources = HashMap::new();
3356        test_sources.insert(
3357            test_path.to_string_lossy().into_owned(),
3358            std::fs::read_to_string(&test_path).unwrap(),
3359        );
3360
3361        let extractor = TypeScriptExtractor::new();
3362
3363        // When: map_test_files_with_imports(scan_root=packages/core/)
3364        let mappings = extractor.map_test_files_with_imports(
3365            &production_files,
3366            &test_sources,
3367            &scan_root,
3368            false,
3369        );
3370
3371        // Then: packages/common/src/foo.ts IS mapped (symlink resolved via Layer 2c)
3372        let common_path_str = common_path.to_string_lossy().into_owned();
3373        let common_mapping = mappings
3374            .iter()
3375            .find(|m| m.production_file == common_path_str);
3376        assert!(
3377            common_mapping.is_some(),
3378            "expected common/src/foo.ts to have a mapping"
3379        );
3380        let test_file_str = test_path.to_string_lossy().into_owned();
3381        assert!(
3382            common_mapping.unwrap().test_files.contains(&test_file_str),
3383            "expected foo.spec.ts to be mapped to common/src/foo.ts via symlink"
3384        );
3385    }
3386
3387    // TS-B2-SYM-01: resolve_node_modules_symlink follows symlink to real path
3388    #[cfg(unix)]
3389    #[test]
3390    fn b2_sym_01_symlink_followed() {
3391        use std::os::unix::fs::symlink;
3392        use tempfile::TempDir;
3393
3394        // Given:
3395        //   scan_root/node_modules/@org/common -> ../../packages/common (symlink)
3396        let dir = TempDir::new().unwrap();
3397        let nm_org = dir.path().join("node_modules").join("@org");
3398        std::fs::create_dir_all(&nm_org).unwrap();
3399        let target = dir.path().join("packages").join("common");
3400        std::fs::create_dir_all(&target).unwrap();
3401        let symlink_path = nm_org.join("common");
3402        symlink(&target, &symlink_path).unwrap();
3403
3404        let mut cache = HashMap::new();
3405
3406        // When: resolve_node_modules_symlink("@org/common", scan_root)
3407        let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3408
3409        // Then: returns Some(canonical path of packages/common)
3410        let expected = target.canonicalize().unwrap();
3411        assert_eq!(
3412            result,
3413            Some(expected),
3414            "expected symlink to be followed to real path"
3415        );
3416    }
3417
3418    // TS-B2-SYM-02: resolve_node_modules_symlink returns None for real directory (not symlink)
3419    #[cfg(unix)]
3420    #[test]
3421    fn b2_sym_02_real_directory_returns_none() {
3422        use tempfile::TempDir;
3423
3424        // Given:
3425        //   scan_root/node_modules/@org/common is a real directory (not symlink)
3426        let dir = TempDir::new().unwrap();
3427        let nm_org = dir.path().join("node_modules").join("@org").join("common");
3428        std::fs::create_dir_all(&nm_org).unwrap();
3429
3430        let mut cache = HashMap::new();
3431
3432        // When: resolve_node_modules_symlink("@org/common", scan_root)
3433        let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3434
3435        // Then: returns None (real directory is not a monorepo symlink)
3436        assert_eq!(
3437            result, None,
3438            "expected None for real directory (not symlink)"
3439        );
3440    }
3441
3442    // TS-B2-SYM-03: resolve_node_modules_symlink returns None for non-existent specifier
3443    #[cfg(unix)]
3444    #[test]
3445    fn b2_sym_03_nonexistent_returns_none() {
3446        use tempfile::TempDir;
3447
3448        // Given: scan_root/node_modules has no @org/nonexistent
3449        let dir = TempDir::new().unwrap();
3450        let nm = dir.path().join("node_modules");
3451        std::fs::create_dir_all(&nm).unwrap();
3452
3453        let mut cache = HashMap::new();
3454
3455        // When: resolve_node_modules_symlink("@org/nonexistent", scan_root)
3456        let result = resolve_node_modules_symlink("@org/nonexistent", dir.path(), &mut cache);
3457
3458        // Then: returns None
3459        assert_eq!(result, None, "expected None for non-existent specifier");
3460    }
3461
3462    // TS-B2-MAP-02: tsconfig alias takes priority over symlink fallback
3463    #[cfg(unix)]
3464    #[test]
3465    fn b2_map_02_tsconfig_alias_priority() {
3466        use std::os::unix::fs::symlink;
3467        use tempfile::TempDir;
3468
3469        // Given:
3470        //   packages/core/ (scan_root)
3471        //   packages/core/src/foo.service.ts (production, tsconfig alias target)
3472        //   packages/common/src/foo.ts (cross-package production)
3473        //   packages/core/node_modules/@org/common -> ../../common (symlink)
3474        //   packages/core/tsconfig.json: paths: { "@org/common": ["src/foo.service"] }
3475        //   packages/core/test/foo.spec.ts: `import { Foo } from '@org/common'`
3476        let dir = TempDir::new().unwrap();
3477        let core_src = dir.path().join("packages").join("core").join("src");
3478        let core_test = dir.path().join("packages").join("core").join("test");
3479        let core_nm_org = dir
3480            .path()
3481            .join("packages")
3482            .join("core")
3483            .join("node_modules")
3484            .join("@org");
3485        let common_src = dir.path().join("packages").join("common").join("src");
3486        std::fs::create_dir_all(&core_src).unwrap();
3487        std::fs::create_dir_all(&core_test).unwrap();
3488        std::fs::create_dir_all(&core_nm_org).unwrap();
3489        std::fs::create_dir_all(&common_src).unwrap();
3490
3491        let local_prod_path = core_src.join("foo.service.ts");
3492        std::fs::write(&local_prod_path, "export class FooService {}").unwrap();
3493
3494        let common_path = common_src.join("foo.ts");
3495        std::fs::File::create(&common_path).unwrap();
3496
3497        let symlink_path = core_nm_org.join("common");
3498        let target = dir.path().join("packages").join("common");
3499        symlink(&target, &symlink_path).unwrap();
3500
3501        // tsconfig.json: alias @org/common -> src/foo.service
3502        let tsconfig = serde_json::json!({
3503            "compilerOptions": {
3504                "paths": {
3505                    "@org/common": ["src/foo.service"]
3506                }
3507            }
3508        });
3509        let core_root = dir.path().join("packages").join("core");
3510        std::fs::write(core_root.join("tsconfig.json"), tsconfig.to_string()).unwrap();
3511
3512        let test_path = core_test.join("foo.spec.ts");
3513        std::fs::write(
3514            &test_path,
3515            "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3516        )
3517        .unwrap();
3518
3519        let production_files = vec![
3520            local_prod_path.to_string_lossy().into_owned(),
3521            common_path.to_string_lossy().into_owned(),
3522        ];
3523        let mut test_sources = HashMap::new();
3524        test_sources.insert(
3525            test_path.to_string_lossy().into_owned(),
3526            std::fs::read_to_string(&test_path).unwrap(),
3527        );
3528
3529        let extractor = TypeScriptExtractor::new();
3530
3531        // When: map_test_files_with_imports with tsconfig alias present
3532        let mappings = extractor.map_test_files_with_imports(
3533            &production_files,
3534            &test_sources,
3535            &core_root,
3536            false,
3537        );
3538
3539        // Then: foo.service.ts is mapped (tsconfig alias wins), common/src/foo.ts is NOT mapped
3540        let test_file_str = test_path.to_string_lossy().into_owned();
3541        let local_prod_str = local_prod_path.to_string_lossy().into_owned();
3542        let common_path_str = common_path.to_string_lossy().into_owned();
3543
3544        let local_mapping = mappings
3545            .iter()
3546            .find(|m| m.production_file == local_prod_str);
3547        assert!(
3548            local_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3549            "expected foo.service.ts to be mapped via tsconfig alias"
3550        );
3551
3552        let common_mapping = mappings
3553            .iter()
3554            .find(|m| m.production_file == common_path_str);
3555        assert!(
3556            !common_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3557            "expected common/src/foo.ts NOT to be mapped (tsconfig alias should win)"
3558        );
3559    }
3560
3561    // TS-B2-MULTI-01: same specifier in 2 test files both get mapped (behavior test)
3562    #[cfg(unix)]
3563    #[test]
3564    fn b2_multi_01_two_test_files_both_mapped() {
3565        use std::os::unix::fs::symlink;
3566        use tempfile::TempDir;
3567
3568        // Given:
3569        //   packages/common/src/foo.ts (production, cross-package)
3570        //   packages/core/node_modules/@org/common -> ../../common (symlink)
3571        //   packages/core/test/foo.spec.ts: `import { Foo } from '@org/common'`
3572        //   packages/core/test/bar.spec.ts: `import { Foo } from '@org/common'`
3573        let dir = TempDir::new().unwrap();
3574        let core_test = dir.path().join("packages").join("core").join("test");
3575        let core_nm_org = dir
3576            .path()
3577            .join("packages")
3578            .join("core")
3579            .join("node_modules")
3580            .join("@org");
3581        let common_src = dir.path().join("packages").join("common").join("src");
3582        std::fs::create_dir_all(&core_test).unwrap();
3583        std::fs::create_dir_all(&core_nm_org).unwrap();
3584        std::fs::create_dir_all(&common_src).unwrap();
3585
3586        let common_path = common_src.join("foo.ts");
3587        std::fs::File::create(&common_path).unwrap();
3588
3589        let symlink_path = core_nm_org.join("common");
3590        let target = dir.path().join("packages").join("common");
3591        symlink(&target, &symlink_path).unwrap();
3592
3593        let test_path1 = core_test.join("foo.spec.ts");
3594        let test_path2 = core_test.join("bar.spec.ts");
3595        std::fs::write(
3596            &test_path1,
3597            "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3598        )
3599        .unwrap();
3600        std::fs::write(
3601            &test_path2,
3602            "import { Foo } from '@org/common';\ndescribe('Bar', () => {});",
3603        )
3604        .unwrap();
3605
3606        let scan_root = dir.path().join("packages").join("core");
3607        let production_files = vec![common_path.to_string_lossy().into_owned()];
3608        let mut test_sources = HashMap::new();
3609        test_sources.insert(
3610            test_path1.to_string_lossy().into_owned(),
3611            std::fs::read_to_string(&test_path1).unwrap(),
3612        );
3613        test_sources.insert(
3614            test_path2.to_string_lossy().into_owned(),
3615            std::fs::read_to_string(&test_path2).unwrap(),
3616        );
3617
3618        let extractor = TypeScriptExtractor::new();
3619
3620        // When: map_test_files_with_imports
3621        let mappings = extractor.map_test_files_with_imports(
3622            &production_files,
3623            &test_sources,
3624            &scan_root,
3625            false,
3626        );
3627
3628        // Then: common/src/foo.ts is mapped to BOTH test files
3629        let common_path_str = common_path.to_string_lossy().into_owned();
3630        let test1_str = test_path1.to_string_lossy().into_owned();
3631        let test2_str = test_path2.to_string_lossy().into_owned();
3632
3633        let common_mapping = mappings
3634            .iter()
3635            .find(|m| m.production_file == common_path_str);
3636        assert!(
3637            common_mapping.is_some(),
3638            "expected common/src/foo.ts to have a mapping"
3639        );
3640        let mapped_tests = &common_mapping.unwrap().test_files;
3641        assert!(
3642            mapped_tests.contains(&test1_str),
3643            "expected foo.spec.ts to be mapped"
3644        );
3645        assert!(
3646            mapped_tests.contains(&test2_str),
3647            "expected bar.spec.ts to be mapped"
3648        );
3649    }
3650
3651    // TC-05: Boundary B3 — tsconfig path alias is treated same as non-relative import
3652    #[test]
3653    fn boundary_b3_tsconfig_alias_not_resolved() {
3654        // Given: source with `import { FooService } from '@app/services/foo.service'`
3655        let source = "import { FooService } from '@app/services/foo.service';";
3656        let extractor = TypeScriptExtractor::new();
3657
3658        // When: extract_imports
3659        let imports = extractor.extract_imports(source, "app.module.ts");
3660
3661        // Then: imports is empty (@app/ is non-relative, same code path as TC-03)
3662        // Note: tsconfig path aliases are treated identically to package imports.
3663        // Same root cause as B2 but different user expectation.
3664        assert!(
3665            imports.is_empty(),
3666            "expected empty imports for tsconfig alias, got {:?}",
3667            imports
3668        );
3669    }
3670
3671    // TC-06: B4 — .enum.ts in production_files is NOT filtered (production-aware bypass)
3672    #[test]
3673    fn boundary_b4_enum_primary_target_filtered() {
3674        use tempfile::TempDir;
3675
3676        // Given:
3677        //   src/route-paramtypes.enum.ts (production)
3678        //   test/route.spec.ts: `import { RouteParamtypes } from '../src/route-paramtypes.enum'`
3679        let dir = TempDir::new().unwrap();
3680        let src_dir = dir.path().join("src");
3681        let test_dir = dir.path().join("test");
3682        std::fs::create_dir_all(&src_dir).unwrap();
3683        std::fs::create_dir_all(&test_dir).unwrap();
3684
3685        let prod_path = src_dir.join("route-paramtypes.enum.ts");
3686        std::fs::File::create(&prod_path).unwrap();
3687
3688        let test_path = test_dir.join("route.spec.ts");
3689        std::fs::write(
3690            &test_path,
3691            "import { RouteParamtypes } from '../src/route-paramtypes.enum';\ndescribe('Route', () => {});",
3692        )
3693        .unwrap();
3694
3695        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3696        let mut test_sources = HashMap::new();
3697        test_sources.insert(
3698            test_path.to_string_lossy().into_owned(),
3699            std::fs::read_to_string(&test_path).unwrap(),
3700        );
3701
3702        let extractor = TypeScriptExtractor::new();
3703
3704        // When: map_test_files_with_imports
3705        let mappings = extractor.map_test_files_with_imports(
3706            &production_files,
3707            &test_sources,
3708            dir.path(),
3709            false,
3710        );
3711
3712        // Then: route-paramtypes.enum.ts IS mapped to route.spec.ts (production-aware bypass)
3713        let enum_mapping = mappings
3714            .iter()
3715            .find(|m| m.production_file.ends_with("route-paramtypes.enum.ts"));
3716        assert!(
3717            enum_mapping.is_some(),
3718            "expected mapping for route-paramtypes.enum.ts"
3719        );
3720        let enum_mapping = enum_mapping.unwrap();
3721        assert!(
3722            !enum_mapping.test_files.is_empty(),
3723            "expected test_files for route-paramtypes.enum.ts (production file), got empty"
3724        );
3725    }
3726
3727    // TC-07: B4 — .interface.ts in production_files is NOT filtered (production-aware bypass)
3728    #[test]
3729    fn boundary_b4_interface_primary_target_filtered() {
3730        use tempfile::TempDir;
3731
3732        // Given:
3733        //   src/user.interface.ts (production)
3734        //   test/user.spec.ts: `import { User } from '../src/user.interface'`
3735        let dir = TempDir::new().unwrap();
3736        let src_dir = dir.path().join("src");
3737        let test_dir = dir.path().join("test");
3738        std::fs::create_dir_all(&src_dir).unwrap();
3739        std::fs::create_dir_all(&test_dir).unwrap();
3740
3741        let prod_path = src_dir.join("user.interface.ts");
3742        std::fs::File::create(&prod_path).unwrap();
3743
3744        let test_path = test_dir.join("user.spec.ts");
3745        std::fs::write(
3746            &test_path,
3747            "import { User } from '../src/user.interface';\ndescribe('User', () => {});",
3748        )
3749        .unwrap();
3750
3751        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3752        let mut test_sources = HashMap::new();
3753        test_sources.insert(
3754            test_path.to_string_lossy().into_owned(),
3755            std::fs::read_to_string(&test_path).unwrap(),
3756        );
3757
3758        let extractor = TypeScriptExtractor::new();
3759
3760        // When: map_test_files_with_imports
3761        let mappings = extractor.map_test_files_with_imports(
3762            &production_files,
3763            &test_sources,
3764            dir.path(),
3765            false,
3766        );
3767
3768        // Then: user.interface.ts IS mapped to user.spec.ts (production-aware bypass)
3769        let iface_mapping = mappings
3770            .iter()
3771            .find(|m| m.production_file.ends_with("user.interface.ts"));
3772        assert!(
3773            iface_mapping.is_some(),
3774            "expected mapping for user.interface.ts"
3775        );
3776        let iface_mapping = iface_mapping.unwrap();
3777        assert!(
3778            !iface_mapping.test_files.is_empty(),
3779            "expected test_files for user.interface.ts (production file), got empty"
3780        );
3781    }
3782
3783    // TC-08: Boundary B5 — dynamic import() is not captured by extract_imports
3784    #[test]
3785    fn boundary_b5_dynamic_import_not_extracted() {
3786        // Given: fixture("import_dynamic.ts") containing `const m = await import('./user.service')`
3787        let source = fixture("import_dynamic.ts");
3788        let extractor = TypeScriptExtractor::new();
3789
3790        // When: extract_imports
3791        let imports = extractor.extract_imports(&source, "import_dynamic.ts");
3792
3793        // Then: imports is empty (dynamic import() not captured by import_mapping.scm)
3794        assert!(
3795            imports.is_empty(),
3796            "expected empty imports for dynamic import(), got {:?}",
3797            imports
3798        );
3799    }
3800
3801    // === tsconfig alias integration tests (OB-01 to OB-06) ===
3802
3803    // OB-01: tsconfig alias basic — @app/foo.service -> src/foo.service.ts
3804    #[test]
3805    fn test_observe_tsconfig_alias_basic() {
3806        use tempfile::TempDir;
3807
3808        // Given:
3809        //   tsconfig.json: @app/* -> src/*
3810        //   src/foo.service.ts (production)
3811        //   test/foo.service.spec.ts: `import { FooService } from '@app/foo.service'`
3812        let dir = TempDir::new().unwrap();
3813        let src_dir = dir.path().join("src");
3814        let test_dir = dir.path().join("test");
3815        std::fs::create_dir_all(&src_dir).unwrap();
3816        std::fs::create_dir_all(&test_dir).unwrap();
3817
3818        let tsconfig = dir.path().join("tsconfig.json");
3819        std::fs::write(
3820            &tsconfig,
3821            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3822        )
3823        .unwrap();
3824
3825        let prod_path = src_dir.join("foo.service.ts");
3826        std::fs::File::create(&prod_path).unwrap();
3827
3828        let test_path = test_dir.join("foo.service.spec.ts");
3829        let test_source =
3830            "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3831        std::fs::write(&test_path, test_source).unwrap();
3832
3833        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3834        let mut test_sources = HashMap::new();
3835        test_sources.insert(
3836            test_path.to_string_lossy().into_owned(),
3837            test_source.to_string(),
3838        );
3839
3840        let extractor = TypeScriptExtractor::new();
3841
3842        // When: map_test_files_with_imports
3843        let mappings = extractor.map_test_files_with_imports(
3844            &production_files,
3845            &test_sources,
3846            dir.path(),
3847            false,
3848        );
3849
3850        // Then: foo.service.ts is mapped to foo.service.spec.ts via alias resolution
3851        let mapping = mappings
3852            .iter()
3853            .find(|m| m.production_file.contains("foo.service.ts"))
3854            .expect("expected mapping for foo.service.ts");
3855        assert!(
3856            mapping
3857                .test_files
3858                .contains(&test_path.to_string_lossy().into_owned()),
3859            "expected foo.service.spec.ts in mapping via alias, got {:?}",
3860            mapping.test_files
3861        );
3862    }
3863
3864    // OB-02: no tsconfig -> alias import produces no mapping
3865    #[test]
3866    fn test_observe_no_tsconfig_alias_ignored() {
3867        use tempfile::TempDir;
3868
3869        // Given:
3870        //   NO tsconfig.json
3871        //   src/foo.service.ts (production)
3872        //   test/foo.service.spec.ts: `import { FooService } from '@app/foo.service'`
3873        let dir = TempDir::new().unwrap();
3874        let src_dir = dir.path().join("src");
3875        let test_dir = dir.path().join("test");
3876        std::fs::create_dir_all(&src_dir).unwrap();
3877        std::fs::create_dir_all(&test_dir).unwrap();
3878
3879        let prod_path = src_dir.join("foo.service.ts");
3880        std::fs::File::create(&prod_path).unwrap();
3881
3882        let test_path = test_dir.join("foo.service.spec.ts");
3883        let test_source =
3884            "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3885
3886        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3887        let mut test_sources = HashMap::new();
3888        test_sources.insert(
3889            test_path.to_string_lossy().into_owned(),
3890            test_source.to_string(),
3891        );
3892
3893        let extractor = TypeScriptExtractor::new();
3894
3895        // When: map_test_files_with_imports (no tsconfig.json present)
3896        let mappings = extractor.map_test_files_with_imports(
3897            &production_files,
3898            &test_sources,
3899            dir.path(),
3900            false,
3901        );
3902
3903        // Then: no test_files mapped (alias import skipped without tsconfig)
3904        let all_test_files: Vec<&String> =
3905            mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3906        assert!(
3907            all_test_files.is_empty(),
3908            "expected no test_files when tsconfig absent, got {:?}",
3909            all_test_files
3910        );
3911    }
3912
3913    // OB-03: tsconfig alias + barrel -> resolves via barrel
3914    #[test]
3915    fn test_observe_tsconfig_alias_barrel() {
3916        use tempfile::TempDir;
3917
3918        // Given:
3919        //   tsconfig: @app/* -> src/*
3920        //   src/bar.service.ts (production)
3921        //   src/services/index.ts (barrel): `export { BarService } from '../bar.service'`
3922        //   test/bar.service.spec.ts: `import { BarService } from '@app/services'`
3923        let dir = TempDir::new().unwrap();
3924        let src_dir = dir.path().join("src");
3925        let services_dir = src_dir.join("services");
3926        let test_dir = dir.path().join("test");
3927        std::fs::create_dir_all(&services_dir).unwrap();
3928        std::fs::create_dir_all(&test_dir).unwrap();
3929
3930        std::fs::write(
3931            dir.path().join("tsconfig.json"),
3932            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3933        )
3934        .unwrap();
3935
3936        let prod_path = src_dir.join("bar.service.ts");
3937        std::fs::File::create(&prod_path).unwrap();
3938
3939        std::fs::write(
3940            services_dir.join("index.ts"),
3941            "export { BarService } from '../bar.service';\n",
3942        )
3943        .unwrap();
3944
3945        let test_path = test_dir.join("bar.service.spec.ts");
3946        let test_source =
3947            "import { BarService } from '@app/services';\ndescribe('BarService', () => {});\n";
3948        std::fs::write(&test_path, test_source).unwrap();
3949
3950        let production_files = vec![prod_path.to_string_lossy().into_owned()];
3951        let mut test_sources = HashMap::new();
3952        test_sources.insert(
3953            test_path.to_string_lossy().into_owned(),
3954            test_source.to_string(),
3955        );
3956
3957        let extractor = TypeScriptExtractor::new();
3958
3959        // When: map_test_files_with_imports
3960        let mappings = extractor.map_test_files_with_imports(
3961            &production_files,
3962            &test_sources,
3963            dir.path(),
3964            false,
3965        );
3966
3967        // Then: bar.service.ts is mapped via alias + barrel resolution
3968        let mapping = mappings
3969            .iter()
3970            .find(|m| m.production_file.contains("bar.service.ts"))
3971            .expect("expected mapping for bar.service.ts");
3972        assert!(
3973            mapping
3974                .test_files
3975                .contains(&test_path.to_string_lossy().into_owned()),
3976            "expected bar.service.spec.ts mapped via alias+barrel, got {:?}",
3977            mapping.test_files
3978        );
3979    }
3980
3981    // OB-04: mixed relative + alias imports -> both resolved
3982    #[test]
3983    fn test_observe_tsconfig_alias_mixed() {
3984        use tempfile::TempDir;
3985
3986        // Given:
3987        //   tsconfig: @app/* -> src/*
3988        //   src/foo.service.ts, src/bar.service.ts (productions)
3989        //   test/mixed.spec.ts:
3990        //     `import { FooService } from '@app/foo.service'`   (alias)
3991        //     `import { BarService } from '../src/bar.service'` (relative)
3992        let dir = TempDir::new().unwrap();
3993        let src_dir = dir.path().join("src");
3994        let test_dir = dir.path().join("test");
3995        std::fs::create_dir_all(&src_dir).unwrap();
3996        std::fs::create_dir_all(&test_dir).unwrap();
3997
3998        std::fs::write(
3999            dir.path().join("tsconfig.json"),
4000            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4001        )
4002        .unwrap();
4003
4004        let foo_path = src_dir.join("foo.service.ts");
4005        let bar_path = src_dir.join("bar.service.ts");
4006        std::fs::File::create(&foo_path).unwrap();
4007        std::fs::File::create(&bar_path).unwrap();
4008
4009        let test_path = test_dir.join("mixed.spec.ts");
4010        let test_source = "\
4011import { FooService } from '@app/foo.service';
4012import { BarService } from '../src/bar.service';
4013describe('Mixed', () => {});
4014";
4015        std::fs::write(&test_path, test_source).unwrap();
4016
4017        let production_files = vec![
4018            foo_path.to_string_lossy().into_owned(),
4019            bar_path.to_string_lossy().into_owned(),
4020        ];
4021        let mut test_sources = HashMap::new();
4022        test_sources.insert(
4023            test_path.to_string_lossy().into_owned(),
4024            test_source.to_string(),
4025        );
4026
4027        let extractor = TypeScriptExtractor::new();
4028
4029        // When: map_test_files_with_imports
4030        let mappings = extractor.map_test_files_with_imports(
4031            &production_files,
4032            &test_sources,
4033            dir.path(),
4034            false,
4035        );
4036
4037        // Then: both foo.service.ts and bar.service.ts are mapped
4038        let foo_mapping = mappings
4039            .iter()
4040            .find(|m| m.production_file.contains("foo.service.ts"))
4041            .expect("expected mapping for foo.service.ts");
4042        assert!(
4043            foo_mapping
4044                .test_files
4045                .contains(&test_path.to_string_lossy().into_owned()),
4046            "expected mixed.spec.ts in foo mapping, got {:?}",
4047            foo_mapping.test_files
4048        );
4049        let bar_mapping = mappings
4050            .iter()
4051            .find(|m| m.production_file.contains("bar.service.ts"))
4052            .expect("expected mapping for bar.service.ts");
4053        assert!(
4054            bar_mapping
4055                .test_files
4056                .contains(&test_path.to_string_lossy().into_owned()),
4057            "expected mixed.spec.ts in bar mapping, got {:?}",
4058            bar_mapping.test_files
4059        );
4060    }
4061
4062    // OB-05: tsconfig alias + is_non_sut_helper filter -> constants.ts is excluded
4063    #[test]
4064    fn test_observe_tsconfig_alias_helper_filtered() {
4065        use tempfile::TempDir;
4066
4067        // Given:
4068        //   tsconfig: @app/* -> src/*
4069        //   src/constants.ts (production, but filtered by is_non_sut_helper)
4070        //   test/constants.spec.ts: `import { APP_NAME } from '@app/constants'`
4071        let dir = TempDir::new().unwrap();
4072        let src_dir = dir.path().join("src");
4073        let test_dir = dir.path().join("test");
4074        std::fs::create_dir_all(&src_dir).unwrap();
4075        std::fs::create_dir_all(&test_dir).unwrap();
4076
4077        std::fs::write(
4078            dir.path().join("tsconfig.json"),
4079            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4080        )
4081        .unwrap();
4082
4083        let prod_path = src_dir.join("constants.ts");
4084        std::fs::File::create(&prod_path).unwrap();
4085
4086        let test_path = test_dir.join("constants.spec.ts");
4087        let test_source =
4088            "import { APP_NAME } from '@app/constants';\ndescribe('Constants', () => {});\n";
4089        std::fs::write(&test_path, test_source).unwrap();
4090
4091        let production_files = vec![prod_path.to_string_lossy().into_owned()];
4092        let mut test_sources = HashMap::new();
4093        test_sources.insert(
4094            test_path.to_string_lossy().into_owned(),
4095            test_source.to_string(),
4096        );
4097
4098        let extractor = TypeScriptExtractor::new();
4099
4100        // When: map_test_files_with_imports
4101        let mappings = extractor.map_test_files_with_imports(
4102            &production_files,
4103            &test_sources,
4104            dir.path(),
4105            false,
4106        );
4107
4108        // Then: constants.ts is filtered by is_non_sut_helper → no test_files
4109        let all_test_files: Vec<&String> =
4110            mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4111        assert!(
4112            all_test_files.is_empty(),
4113            "expected constants.ts filtered by is_non_sut_helper, got {:?}",
4114            all_test_files
4115        );
4116    }
4117
4118    // OB-06: alias to nonexistent file -> no mapping, no error
4119    #[test]
4120    fn test_observe_tsconfig_alias_nonexistent() {
4121        use tempfile::TempDir;
4122
4123        // Given:
4124        //   tsconfig: @app/* -> src/*
4125        //   src/foo.service.ts (production)
4126        //   test/nonexistent.spec.ts: `import { Missing } from '@app/nonexistent'`
4127        //   (src/nonexistent.ts does NOT exist)
4128        let dir = TempDir::new().unwrap();
4129        let src_dir = dir.path().join("src");
4130        let test_dir = dir.path().join("test");
4131        std::fs::create_dir_all(&src_dir).unwrap();
4132        std::fs::create_dir_all(&test_dir).unwrap();
4133
4134        std::fs::write(
4135            dir.path().join("tsconfig.json"),
4136            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4137        )
4138        .unwrap();
4139
4140        let prod_path = src_dir.join("foo.service.ts");
4141        std::fs::File::create(&prod_path).unwrap();
4142
4143        let test_path = test_dir.join("nonexistent.spec.ts");
4144        let test_source =
4145            "import { Missing } from '@app/nonexistent';\ndescribe('Nonexistent', () => {});\n";
4146        std::fs::write(&test_path, test_source).unwrap();
4147
4148        let production_files = vec![prod_path.to_string_lossy().into_owned()];
4149        let mut test_sources = HashMap::new();
4150        test_sources.insert(
4151            test_path.to_string_lossy().into_owned(),
4152            test_source.to_string(),
4153        );
4154
4155        let extractor = TypeScriptExtractor::new();
4156
4157        // When: map_test_files_with_imports (should not panic)
4158        let mappings = extractor.map_test_files_with_imports(
4159            &production_files,
4160            &test_sources,
4161            dir.path(),
4162            false,
4163        );
4164
4165        // Then: no mapping (nonexistent.ts not in production_files), no panic
4166        let all_test_files: Vec<&String> =
4167            mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4168        assert!(
4169            all_test_files.is_empty(),
4170            "expected no mapping for alias to nonexistent file, got {:?}",
4171            all_test_files
4172        );
4173    }
4174
4175    // B3-update: boundary_b3_tsconfig_alias_resolved
4176    // With tsconfig.json present, @app/* alias SHOULD be resolved (FN → TP)
4177    #[test]
4178    fn boundary_b3_tsconfig_alias_resolved() {
4179        use tempfile::TempDir;
4180
4181        // Given:
4182        //   tsconfig.json: @app/* -> src/*
4183        //   src/foo.service.ts (production)
4184        //   test/foo.service.spec.ts: `import { FooService } from '@app/services/foo.service'`
4185        let dir = TempDir::new().unwrap();
4186        let src_dir = dir.path().join("src");
4187        let services_dir = src_dir.join("services");
4188        let test_dir = dir.path().join("test");
4189        std::fs::create_dir_all(&services_dir).unwrap();
4190        std::fs::create_dir_all(&test_dir).unwrap();
4191
4192        std::fs::write(
4193            dir.path().join("tsconfig.json"),
4194            r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4195        )
4196        .unwrap();
4197
4198        let prod_path = services_dir.join("foo.service.ts");
4199        std::fs::File::create(&prod_path).unwrap();
4200
4201        let test_path = test_dir.join("foo.service.spec.ts");
4202        let test_source = "import { FooService } from '@app/services/foo.service';\ndescribe('FooService', () => {});\n";
4203        std::fs::write(&test_path, test_source).unwrap();
4204
4205        let production_files = vec![prod_path.to_string_lossy().into_owned()];
4206        let mut test_sources = HashMap::new();
4207        test_sources.insert(
4208            test_path.to_string_lossy().into_owned(),
4209            test_source.to_string(),
4210        );
4211
4212        let extractor = TypeScriptExtractor::new();
4213
4214        // When: map_test_files_with_imports WITH tsconfig present
4215        let mappings = extractor.map_test_files_with_imports(
4216            &production_files,
4217            &test_sources,
4218            dir.path(),
4219            false,
4220        );
4221
4222        // Then: foo.service.ts IS mapped (B3 resolved — FN → TP)
4223        let mapping = mappings
4224            .iter()
4225            .find(|m| m.production_file.contains("foo.service.ts"))
4226            .expect("expected FileMapping for foo.service.ts");
4227        assert!(
4228            mapping
4229                .test_files
4230                .contains(&test_path.to_string_lossy().into_owned()),
4231            "expected tsconfig alias to be resolved (B3 fix), got {:?}",
4232            mapping.test_files
4233        );
4234    }
4235
4236    // TC-09: Boundary B6 — import target outside scan_root is not mapped
4237    #[test]
4238    fn boundary_b6_import_outside_scan_root() {
4239        use tempfile::TempDir;
4240
4241        // Given:
4242        //   packages/core/ (scan_root)
4243        //   packages/core/src/foo.service.ts (production)
4244        //   packages/common/src/shared.ts (outside scan_root)
4245        //   packages/core/test/foo.spec.ts: `import { Shared } from '../../common/src/shared'`
4246        let dir = TempDir::new().unwrap();
4247        let core_src = dir.path().join("packages").join("core").join("src");
4248        let core_test = dir.path().join("packages").join("core").join("test");
4249        let common_src = dir.path().join("packages").join("common").join("src");
4250        std::fs::create_dir_all(&core_src).unwrap();
4251        std::fs::create_dir_all(&core_test).unwrap();
4252        std::fs::create_dir_all(&common_src).unwrap();
4253
4254        let prod_path = core_src.join("foo.service.ts");
4255        std::fs::File::create(&prod_path).unwrap();
4256
4257        // shared.ts is outside scan_root (packages/core/)
4258        let shared_path = common_src.join("shared.ts");
4259        std::fs::File::create(&shared_path).unwrap();
4260
4261        let test_path = core_test.join("foo.spec.ts");
4262        std::fs::write(
4263            &test_path,
4264            "import { Shared } from '../../common/src/shared';\ndescribe('Foo', () => {});",
4265        )
4266        .unwrap();
4267
4268        let scan_root = dir.path().join("packages").join("core");
4269        // Only production files within scan_root are registered
4270        let production_files = vec![prod_path.to_string_lossy().into_owned()];
4271        let mut test_sources = HashMap::new();
4272        test_sources.insert(
4273            test_path.to_string_lossy().into_owned(),
4274            std::fs::read_to_string(&test_path).unwrap(),
4275        );
4276
4277        let extractor = TypeScriptExtractor::new();
4278
4279        // When: map_test_files_with_imports(scan_root=packages/core/)
4280        let mappings = extractor.map_test_files_with_imports(
4281            &production_files,
4282            &test_sources,
4283            &scan_root,
4284            false,
4285        );
4286
4287        // Then: shared.ts outside scan_root is not in production_files, so no mapping occurs.
4288        // `../../common/src/shared` resolves outside scan_root; it won't be in production_files
4289        // and won't match foo.service.ts by filename either.
4290        let all_test_files: Vec<&String> =
4291            mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4292        assert!(
4293            all_test_files.is_empty(),
4294            "expected no test_files (import target outside scan_root), got {:?}",
4295            all_test_files
4296        );
4297    }
4298
4299    // === B1 namespace re-export fix tests (TS-B1-NS-01 to TS-B1-NS-04) ===
4300
4301    // TS-B1-NS-01: namespace re-export が wildcard として抽出される
4302    #[test]
4303    fn test_b1_ns_reexport_extracted_as_wildcard() {
4304        // Given: `export * as Validators from './validators';`
4305        let source = "export * as Validators from './validators';";
4306        let extractor = TypeScriptExtractor::new();
4307
4308        // When: extract_barrel_re_exports
4309        let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4310
4311        // Then: 1件返る: from_specifier="./validators", wildcard=true
4312        assert_eq!(
4313            re_exports.len(),
4314            1,
4315            "expected 1 re-export, got {:?}",
4316            re_exports
4317        );
4318        let re = &re_exports[0];
4319        assert_eq!(re.from_specifier, "./validators");
4320        assert!(
4321            re.wildcard,
4322            "expected wildcard=true for `export * as Ns from`, got {:?}",
4323            re
4324        );
4325    }
4326
4327    // TS-B1-NS-02: namespace re-export 経由の mapping が TP になる
4328    #[test]
4329    fn test_b1_ns_reexport_mapping_resolves_via_layer2() {
4330        use tempfile::TempDir;
4331
4332        // Given:
4333        //   validators/foo.service.ts (production)
4334        //   index.ts: `export * as Ns from './validators'`
4335        //   validators/index.ts: `export { FooService } from './foo.service'`
4336        //   test/foo.spec.ts: `import { Ns } from '../index'`
4337        let dir = TempDir::new().unwrap();
4338        let validators_dir = dir.path().join("validators");
4339        let test_dir = dir.path().join("test");
4340        std::fs::create_dir_all(&validators_dir).unwrap();
4341        std::fs::create_dir_all(&test_dir).unwrap();
4342
4343        let prod_path = validators_dir.join("foo.service.ts");
4344        std::fs::File::create(&prod_path).unwrap();
4345
4346        std::fs::write(
4347            dir.path().join("index.ts"),
4348            "export * as Ns from './validators';",
4349        )
4350        .unwrap();
4351        std::fs::write(
4352            validators_dir.join("index.ts"),
4353            "export { FooService } from './foo.service';",
4354        )
4355        .unwrap();
4356
4357        let test_path = test_dir.join("foo.spec.ts");
4358        std::fs::write(
4359            &test_path,
4360            "import { Ns } from '../index';\ndescribe('FooService', () => {});",
4361        )
4362        .unwrap();
4363
4364        let production_files = vec![prod_path.to_string_lossy().into_owned()];
4365        let mut test_sources = HashMap::new();
4366        test_sources.insert(
4367            test_path.to_string_lossy().into_owned(),
4368            std::fs::read_to_string(&test_path).unwrap(),
4369        );
4370
4371        let extractor = TypeScriptExtractor::new();
4372
4373        // When: map_test_files_with_imports
4374        let mappings = extractor.map_test_files_with_imports(
4375            &production_files,
4376            &test_sources,
4377            dir.path(),
4378            false,
4379        );
4380
4381        // Then: foo.service.ts が test_files にマッチ (Layer 2 via namespace re-export)
4382        let prod_mapping = mappings
4383            .iter()
4384            .find(|m| m.production_file.contains("foo.service.ts"));
4385        assert!(
4386            prod_mapping.is_some(),
4387            "expected foo.service.ts in mappings, got {:?}",
4388            mappings
4389        );
4390        let mapping = prod_mapping.unwrap();
4391        assert!(
4392            !mapping.test_files.is_empty(),
4393            "expected foo.service.ts mapped to foo.spec.ts via namespace re-export, got {:?}",
4394            mapping
4395        );
4396        assert!(
4397            mapping.test_files.iter().any(|f| f.contains("foo.spec.ts")),
4398            "expected foo.spec.ts in test_files, got {:?}",
4399            mapping.test_files
4400        );
4401    }
4402
4403    // TS-B1-NS-03: 通常の wildcard と namespace re-export の混在
4404    #[test]
4405    fn test_b1_ns_reexport_mixed_with_plain_wildcard() {
4406        // Given: `export * from './a'; export * as B from './b';`
4407        let source = "export * from './a';\nexport * as B from './b';";
4408        let extractor = TypeScriptExtractor::new();
4409
4410        // When: extract_barrel_re_exports
4411        let mut re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4412
4413        // Then: 2件返る: 両方 wildcard=true
4414        assert_eq!(
4415            re_exports.len(),
4416            2,
4417            "expected 2 re-exports, got {:?}",
4418            re_exports
4419        );
4420
4421        re_exports.sort_by(|a, b| a.from_specifier.cmp(&b.from_specifier));
4422
4423        let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4424        let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4425
4426        assert!(
4427            re_a.is_some(),
4428            "expected re-export from './a', got {:?}",
4429            re_exports
4430        );
4431        assert!(
4432            re_b.is_some(),
4433            "expected re-export from './b', got {:?}",
4434            re_exports
4435        );
4436        let a = re_a.unwrap();
4437        let b = re_b.unwrap();
4438        assert!(
4439            a.wildcard,
4440            "expected wildcard=true for plain `export * from './a'`, got {:?}",
4441            a
4442        );
4443        assert!(
4444            !a.namespace_wildcard,
4445            "expected namespace_wildcard=false for plain wildcard, got {:?}",
4446            a
4447        );
4448        assert!(
4449            b.wildcard,
4450            "expected wildcard=true for namespace `export * as B from './b'`, got {:?}",
4451            b
4452        );
4453        assert!(
4454            b.namespace_wildcard,
4455            "expected namespace_wildcard=true for namespace re-export, got {:?}",
4456            b
4457        );
4458    }
4459
4460    // TS-B1-NS-04: named re-export との混在
4461    #[test]
4462    fn test_b1_ns_reexport_mixed_with_named_reexport() {
4463        // Given: `export { Foo } from './a'; export * as B from './b';`
4464        let source = "export { Foo } from './a';\nexport * as B from './b';";
4465        let extractor = TypeScriptExtractor::new();
4466
4467        // When: extract_barrel_re_exports
4468        let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4469
4470        // Then: 2件: a は symbols=["Foo"], b は wildcard=true
4471        assert_eq!(
4472            re_exports.len(),
4473            2,
4474            "expected 2 re-exports, got {:?}",
4475            re_exports
4476        );
4477
4478        let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4479        let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4480
4481        assert!(
4482            re_a.is_some(),
4483            "expected re-export from './a', got {:?}",
4484            re_exports
4485        );
4486        assert!(
4487            re_b.is_some(),
4488            "expected re-export from './b', got {:?}",
4489            re_exports
4490        );
4491
4492        let re_a = re_a.unwrap();
4493        assert!(
4494            !re_a.wildcard,
4495            "expected wildcard=false for named re-export from './a', got {:?}",
4496            re_a
4497        );
4498        assert_eq!(
4499            re_a.symbols,
4500            vec!["Foo".to_string()],
4501            "expected symbols=[\"Foo\"] for './a', got {:?}",
4502            re_a.symbols
4503        );
4504
4505        let re_b = re_b.unwrap();
4506        assert!(
4507            re_b.wildcard,
4508            "expected wildcard=true for namespace re-export from './b', got {:?}",
4509            re_b
4510        );
4511    }
4512
4513    // === file_path_to_route_path Tests (NX-FP-01 ~ NX-FP-09) ===
4514
4515    // NX-FP-01: basic app router path
4516    #[test]
4517    fn nx_fp_01_basic_app_router_path() {
4518        // Given: a route.ts file at app/api/users/route.ts
4519        // When: convert to route path
4520        let result = file_path_to_route_path("app/api/users/route.ts");
4521        // Then: returns "/api/users"
4522        assert_eq!(result, Some("/api/users".to_string()));
4523    }
4524
4525    // NX-FP-02: src/app prefix is stripped
4526    #[test]
4527    fn nx_fp_02_src_app_prefix_stripped() {
4528        // Given: a route.ts file under src/app/
4529        // When: convert to route path
4530        let result = file_path_to_route_path("src/app/api/users/route.ts");
4531        // Then: src/ prefix is stripped and returns "/api/users"
4532        assert_eq!(result, Some("/api/users".to_string()));
4533    }
4534
4535    // NX-FP-03: dynamic segment [id] → :id
4536    #[test]
4537    fn nx_fp_03_dynamic_segment() {
4538        // Given: a route file with a dynamic segment [id]
4539        // When: convert to route path
4540        let result = file_path_to_route_path("app/api/users/[id]/route.ts");
4541        // Then: [id] is converted to :id
4542        assert_eq!(result, Some("/api/users/:id".to_string()));
4543    }
4544
4545    // NX-FP-04: route group (admin) is removed
4546    #[test]
4547    fn nx_fp_04_route_group_removed() {
4548        // Given: a route file inside a route group (admin)
4549        // When: convert to route path
4550        let result = file_path_to_route_path("app/(admin)/api/route.ts");
4551        // Then: (admin) group segment is removed
4552        assert_eq!(result, Some("/api".to_string()));
4553    }
4554
4555    // NX-FP-05: route.tsx extension is accepted
4556    #[test]
4557    fn nx_fp_05_route_tsx_extension() {
4558        // Given: a route.tsx file
4559        // When: convert to route path
4560        let result = file_path_to_route_path("app/api/route.tsx");
4561        // Then: returns "/api"
4562        assert_eq!(result, Some("/api".to_string()));
4563    }
4564
4565    // NX-FP-06: non-route file is rejected
4566    #[test]
4567    fn nx_fp_06_non_route_file_rejected() {
4568        // Given: a page.ts file (not a route handler)
4569        // When: convert to route path
4570        let result = file_path_to_route_path("app/api/users/page.ts");
4571        // Then: returns None
4572        assert_eq!(result, None);
4573    }
4574
4575    // NX-FP-07: catch-all segment [...slug] → :slug*
4576    #[test]
4577    fn nx_fp_07_catch_all_segment() {
4578        // Given: a route file with catch-all segment [...slug]
4579        // When: convert to route path
4580        let result = file_path_to_route_path("app/api/[...slug]/route.ts");
4581        // Then: [...slug] is converted to :slug*
4582        assert_eq!(result, Some("/api/:slug*".to_string()));
4583    }
4584
4585    // NX-FP-08: optional catch-all [[...slug]] → :slug*?
4586    #[test]
4587    fn nx_fp_08_optional_catch_all_segment() {
4588        // Given: a route file with optional catch-all segment [[...slug]]
4589        // When: convert to route path
4590        let result = file_path_to_route_path("app/api/[[...slug]]/route.ts");
4591        // Then: [[...slug]] is converted to :slug*?
4592        assert_eq!(result, Some("/api/:slug*?".to_string()));
4593    }
4594
4595    // NX-FP-09: root route returns "/"
4596    #[test]
4597    fn nx_fp_09_root_route() {
4598        // Given: app/route.ts (root-level route handler)
4599        // When: convert to route path
4600        let result = file_path_to_route_path("app/route.ts");
4601        // Then: returns "/"
4602        assert_eq!(result, Some("/".to_string()));
4603    }
4604
4605    // === extract_nextjs_routes Tests (NX-RT-01 ~ NX-RT-08) ===
4606
4607    // NX-RT-01: basic GET handler returns 1 route
4608    #[test]
4609    fn nx_rt_01_basic_get_handler() {
4610        // Given: a route.ts with a single exported GET handler
4611        let source = "export async function GET(request: Request) { return Response.json([]); }";
4612        let extractor = TypeScriptExtractor::new();
4613
4614        // When: extract Next.js routes
4615        let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4616
4617        // Then: 1 route, method=GET, path="/api/users"
4618        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4619        assert_eq!(routes[0].http_method, "GET");
4620        assert_eq!(routes[0].path, "/api/users");
4621        assert_eq!(routes[0].handler_name, "GET");
4622    }
4623
4624    // NX-RT-02: multiple HTTP methods (GET + POST) return 2 routes
4625    #[test]
4626    fn nx_rt_02_multiple_http_methods() {
4627        // Given: a route.ts with exported GET and POST handlers
4628        let source = r#"
4629export async function GET() { return Response.json([]); }
4630export async function POST() { return Response.json({}); }
4631"#;
4632        let extractor = TypeScriptExtractor::new();
4633
4634        // When: extract Next.js routes
4635        let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4636
4637        // Then: 2 routes, same path "/api/users", methods GET and POST
4638        assert_eq!(routes.len(), 2, "expected 2 routes, got {:?}", routes);
4639        let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
4640        assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
4641        assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
4642        for r in &routes {
4643            assert_eq!(r.path, "/api/users");
4644        }
4645    }
4646
4647    // NX-RT-03: dynamic segment in path
4648    #[test]
4649    fn nx_rt_03_dynamic_segment_path() {
4650        // Given: a route.ts in a dynamic segment directory
4651        let source = "export async function GET() { return Response.json({}); }";
4652        let extractor = TypeScriptExtractor::new();
4653
4654        // When: extract Next.js routes
4655        let routes = extractor.extract_nextjs_routes(source, "app/api/users/[id]/route.ts");
4656
4657        // Then: path = "/api/users/:id"
4658        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4659        assert_eq!(routes[0].path, "/api/users/:id");
4660    }
4661
4662    // NX-RT-04: non-route file returns empty Vec
4663    #[test]
4664    fn nx_rt_04_non_route_file_returns_empty() {
4665        // Given: a page.ts file with exported GET (not a route handler file)
4666        let source = "export async function GET() { return null; }";
4667        let extractor = TypeScriptExtractor::new();
4668
4669        // When: extract Next.js routes from a non-route file
4670        let routes = extractor.extract_nextjs_routes(source, "app/api/users/page.ts");
4671
4672        // Then: empty Vec (page.ts is not a route handler)
4673        assert!(
4674            routes.is_empty(),
4675            "expected empty routes for page.ts, got {:?}",
4676            routes
4677        );
4678    }
4679
4680    // NX-RT-05: no HTTP method exports returns empty Vec
4681    #[test]
4682    fn nx_rt_05_no_http_method_exports_returns_empty() {
4683        // Given: a route.ts with only non-HTTP-method exports
4684        let source = "export function helper() { return null; }";
4685        let extractor = TypeScriptExtractor::new();
4686
4687        // When: extract Next.js routes
4688        let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4689
4690        // Then: empty Vec (helper is not an HTTP method)
4691        assert!(
4692            routes.is_empty(),
4693            "expected empty routes for helper(), got {:?}",
4694            routes
4695        );
4696    }
4697
4698    // NX-RT-06: arrow function export is recognized
4699    #[test]
4700    fn nx_rt_06_arrow_function_export() {
4701        // Given: a route.ts with an arrow function export for GET
4702        let source = "export const GET = async () => Response.json([]);";
4703        let extractor = TypeScriptExtractor::new();
4704
4705        // When: extract Next.js routes
4706        let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4707
4708        // Then: 1 route, method=GET, path="/api"
4709        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4710        assert_eq!(routes[0].http_method, "GET");
4711        assert_eq!(routes[0].path, "/api");
4712        assert_eq!(routes[0].handler_name, "GET");
4713    }
4714
4715    // NX-RT-07: empty source returns empty Vec
4716    #[test]
4717    fn nx_rt_07_empty_source_returns_empty() {
4718        // Given: empty source code
4719        let extractor = TypeScriptExtractor::new();
4720
4721        // When: extract Next.js routes from empty string
4722        let routes = extractor.extract_nextjs_routes("", "app/api/route.ts");
4723
4724        // Then: empty Vec
4725        assert!(routes.is_empty(), "expected empty routes for empty source");
4726    }
4727
4728    // TS-L1EX-01: l1_exclusive=true suppresses L2 mappings for L1-matched test files
4729    #[test]
4730    fn ts_l1ex_01_l1_exclusive_suppresses_l2() {
4731        use tempfile::TempDir;
4732
4733        // Given:
4734        //   production: src/user.service.ts (L1 match target)
4735        //   production: src/auth.service.ts (L2 match target via import)
4736        //   test: src/user.service.spec.ts
4737        //     - L1 matched to user.service.ts (filename convention)
4738        //     - imports auth.service.ts (would L2 match auth.service.ts)
4739        let dir = TempDir::new().unwrap();
4740        let src_dir = dir.path().join("src");
4741        std::fs::create_dir_all(&src_dir).unwrap();
4742
4743        let user_service = src_dir.join("user.service.ts");
4744        std::fs::File::create(&user_service).unwrap();
4745        let auth_service = src_dir.join("auth.service.ts");
4746        std::fs::File::create(&auth_service).unwrap();
4747
4748        let test_file = src_dir.join("user.service.spec.ts");
4749        // This test file is L1-matched (same stem) AND imports auth.service (L2 candidate)
4750        let test_source =
4751            "import { AuthService } from './auth.service';\ndescribe('UserService', () => {});\n";
4752
4753        let production_files = vec![
4754            user_service.to_string_lossy().into_owned(),
4755            auth_service.to_string_lossy().into_owned(),
4756        ];
4757        let mut test_sources = HashMap::new();
4758        test_sources.insert(
4759            test_file.to_string_lossy().into_owned(),
4760            test_source.to_string(),
4761        );
4762
4763        let extractor = TypeScriptExtractor::new();
4764
4765        // When: l1_exclusive=true
4766        let mappings_exclusive = extractor.map_test_files_with_imports(
4767            &production_files,
4768            &test_sources,
4769            dir.path(),
4770            true,
4771        );
4772
4773        // Then: user.service.ts is mapped (L1), auth.service.ts is NOT mapped (L2 suppressed)
4774        let user_mapping = mappings_exclusive
4775            .iter()
4776            .find(|m| m.production_file.contains("user.service.ts"));
4777        assert!(
4778            user_mapping.is_some(),
4779            "expected user.service.ts in mappings, got {:?}",
4780            mappings_exclusive
4781        );
4782        assert!(
4783            !user_mapping.unwrap().test_files.is_empty(),
4784            "expected user.service.ts mapped to user.service.spec.ts, got {:?}",
4785            user_mapping.unwrap().test_files
4786        );
4787
4788        let auth_mapping = mappings_exclusive
4789            .iter()
4790            .find(|m| m.production_file.contains("auth.service.ts"));
4791        assert!(
4792            auth_mapping
4793                .map(|m| m.test_files.is_empty())
4794                .unwrap_or(true),
4795            "expected auth.service.ts NOT mapped when l1_exclusive=true, got {:?}",
4796            auth_mapping
4797        );
4798
4799        // When: l1_exclusive=false (default behavior)
4800        let mappings_default = extractor.map_test_files_with_imports(
4801            &production_files,
4802            &test_sources,
4803            dir.path(),
4804            false,
4805        );
4806
4807        // Then: auth.service.ts IS mapped via L2
4808        let auth_mapping_default = mappings_default
4809            .iter()
4810            .find(|m| m.production_file.contains("auth.service.ts"));
4811        assert!(
4812            auth_mapping_default
4813                .map(|m| !m.test_files.is_empty())
4814                .unwrap_or(false),
4815            "expected auth.service.ts mapped when l1_exclusive=false, got {:?}",
4816            auth_mapping_default
4817        );
4818    }
4819
4820    // NX-RT-08: route group in path is removed
4821    #[test]
4822    fn nx_rt_08_route_group_in_path() {
4823        // Given: a route.ts inside a route group (auth)
4824        let source = "export async function GET() { return Response.json({}); }";
4825        let extractor = TypeScriptExtractor::new();
4826
4827        // When: extract Next.js routes
4828        let routes = extractor.extract_nextjs_routes(source, "app/(auth)/api/login/route.ts");
4829
4830        // Then: path = "/api/login" (route group is removed)
4831        assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4832        assert_eq!(routes[0].path, "/api/login");
4833    }
4834}