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