Skip to main content

exspec_lang_typescript/
observe.rs

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