Skip to main content

exspec_core/
observe.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4// ---------------------------------------------------------------------------
5// Types
6// ---------------------------------------------------------------------------
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct ProductionFunction {
10    pub name: String,
11    pub file: String,
12    pub line: usize,
13    pub class_name: Option<String>,
14    pub is_exported: bool,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct FileMapping {
19    pub production_file: String,
20    pub test_files: Vec<String>,
21    pub strategy: MappingStrategy,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum MappingStrategy {
26    FileNameConvention,
27    ImportTracing,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ImportMapping {
32    pub symbol_name: String,
33    pub module_specifier: String,
34    pub file: String,
35    pub line: usize,
36    pub symbols: Vec<String>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct BarrelReExport {
41    pub symbols: Vec<String>,
42    pub from_specifier: String,
43    pub wildcard: bool,
44    /// True when this is a namespace re-export (`export * as Ns from '...'`).
45    /// The namespace name changes the symbol space, so nested resolution must
46    /// treat this as an opaque wildcard (symbols dropped on recursion).
47    pub namespace_wildcard: bool,
48}
49
50// ---------------------------------------------------------------------------
51// Trait
52// ---------------------------------------------------------------------------
53
54pub trait ObserveExtractor: Send + Sync {
55    fn extract_production_functions(
56        &self,
57        source: &str,
58        file_path: &str,
59    ) -> Vec<ProductionFunction>;
60    fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping>;
61    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)>;
62    fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport>;
63    fn source_extensions(&self) -> &[&str];
64    fn index_file_names(&self) -> &[&str];
65    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str>;
66    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str>;
67    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool;
68
69    // Default implementations
70    fn is_barrel_file(&self, path: &str) -> bool {
71        let file_name = Path::new(path)
72            .file_name()
73            .and_then(|f| f.to_str())
74            .unwrap_or("");
75        self.index_file_names().contains(&file_name)
76    }
77
78    fn file_exports_any_symbol(&self, _path: &Path, _symbols: &[String]) -> bool {
79        true
80    }
81
82    fn resolve_alias_imports(
83        &self,
84        _source: &str,
85        _scan_root: &Path,
86    ) -> Vec<(String, Vec<String>, Option<PathBuf>)> {
87        Vec::new()
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Free functions
93// ---------------------------------------------------------------------------
94
95pub const MAX_BARREL_DEPTH: usize = 3;
96
97/// Layer 1: Map test files to production files by filename convention (stem matching).
98pub fn map_test_files(
99    ext: &dyn ObserveExtractor,
100    production_files: &[String],
101    test_files: &[String],
102) -> Vec<FileMapping> {
103    let mut tests_by_key: HashMap<(String, String), Vec<String>> = HashMap::new();
104
105    for test_file in test_files {
106        let Some(stem) = ext.test_stem(test_file) else {
107            continue;
108        };
109        let directory = Path::new(test_file)
110            .parent()
111            .map(|parent| parent.to_string_lossy().into_owned())
112            .unwrap_or_default();
113        tests_by_key
114            .entry((directory, stem.to_string()))
115            .or_default()
116            .push(test_file.clone());
117    }
118
119    production_files
120        .iter()
121        .map(|production_file| {
122            let test_matches = ext
123                .production_stem(production_file)
124                .and_then(|stem| {
125                    let directory = Path::new(production_file)
126                        .parent()
127                        .map(|parent| parent.to_string_lossy().into_owned())
128                        .unwrap_or_default();
129                    tests_by_key.get(&(directory, stem.to_string()))
130                })
131                .cloned()
132                .unwrap_or_default();
133            FileMapping {
134                production_file: production_file.clone(),
135                test_files: test_matches,
136                strategy: MappingStrategy::FileNameConvention,
137            }
138        })
139        .collect()
140}
141
142/// Resolve a module specifier to an absolute file path.
143/// Returns None if the file does not exist or is outside scan_root.
144pub fn resolve_import_path(
145    ext: &dyn ObserveExtractor,
146    module_specifier: &str,
147    from_file: &Path,
148    scan_root: &Path,
149) -> Option<String> {
150    let base_dir_raw = from_file.parent()?;
151    let base_dir = base_dir_raw
152        .canonicalize()
153        .unwrap_or_else(|_| base_dir_raw.to_path_buf());
154    let raw_path = base_dir.join(module_specifier);
155    let canonical_root = scan_root.canonicalize().ok()?;
156    resolve_absolute_base_to_file(ext, &raw_path, &canonical_root)
157}
158
159/// Resolve an already-computed absolute base path to an actual source file.
160///
161/// Probes in order:
162/// 1. Direct hit (when `base` already has a known extension).
163/// 2. Append each known extension.
164/// 3. Directory index fallback.
165pub fn resolve_absolute_base_to_file(
166    ext: &dyn ObserveExtractor,
167    base: &Path,
168    canonical_root: &Path,
169) -> Option<String> {
170    let extensions = ext.source_extensions();
171    let has_known_ext = base
172        .extension()
173        .and_then(|e| e.to_str())
174        .is_some_and(|e| extensions.contains(&e));
175
176    let candidates: Vec<PathBuf> = if has_known_ext {
177        vec![base.to_path_buf()]
178    } else {
179        let base_str = base.as_os_str().to_string_lossy();
180        extensions
181            .iter()
182            .map(|e| PathBuf::from(format!("{base_str}.{e}")))
183            .collect()
184    };
185
186    for candidate in &candidates {
187        if let Ok(canonical) = candidate.canonicalize() {
188            if canonical.starts_with(canonical_root) {
189                return Some(canonical.to_string_lossy().into_owned());
190            }
191        }
192    }
193
194    // Fallback: directory index
195    if !has_known_ext {
196        let base_str = base.as_os_str().to_string_lossy();
197        for index_name in ext.index_file_names() {
198            let candidate = PathBuf::from(format!("{base_str}/{index_name}"));
199            if let Ok(canonical) = candidate.canonicalize() {
200                if canonical.starts_with(canonical_root) {
201                    return Some(canonical.to_string_lossy().into_owned());
202                }
203            }
204        }
205    }
206
207    None
208}
209
210/// Resolve barrel re-exports starting from `barrel_path` for the given `symbols`.
211/// Follows up to MAX_BARREL_DEPTH hops, prevents cycles via `visited` set.
212pub fn resolve_barrel_exports(
213    ext: &dyn ObserveExtractor,
214    barrel_path: &Path,
215    symbols: &[String],
216    scan_root: &Path,
217) -> Vec<PathBuf> {
218    let canonical_root = match scan_root.canonicalize() {
219        Ok(r) => r,
220        Err(_) => return Vec::new(),
221    };
222    let mut visited: HashSet<PathBuf> = HashSet::new();
223    let mut results: Vec<PathBuf> = Vec::new();
224    resolve_barrel_exports_inner(
225        ext,
226        barrel_path,
227        symbols,
228        scan_root,
229        &canonical_root,
230        &mut visited,
231        0,
232        &mut results,
233    );
234    results
235}
236
237#[allow(clippy::too_many_arguments)]
238fn resolve_barrel_exports_inner(
239    ext: &dyn ObserveExtractor,
240    barrel_path: &Path,
241    symbols: &[String],
242    scan_root: &Path,
243    canonical_root: &Path,
244    visited: &mut HashSet<PathBuf>,
245    depth: usize,
246    results: &mut Vec<PathBuf>,
247) {
248    if depth >= MAX_BARREL_DEPTH {
249        return;
250    }
251
252    let canonical_barrel = match barrel_path.canonicalize() {
253        Ok(p) => p,
254        Err(_) => return,
255    };
256    if !visited.insert(canonical_barrel) {
257        return;
258    }
259
260    let source = match std::fs::read_to_string(barrel_path) {
261        Ok(s) => s,
262        Err(_) => return,
263    };
264
265    let re_exports = ext.extract_barrel_re_exports(&source, &barrel_path.to_string_lossy());
266
267    for re_export in &re_exports {
268        if !re_export.wildcard {
269            let has_match =
270                symbols.is_empty() || symbols.iter().any(|s| re_export.symbols.contains(s));
271            if !has_match {
272                continue;
273            }
274        }
275
276        if let Some(resolved_str) =
277            resolve_import_path(ext, &re_export.from_specifier, barrel_path, scan_root)
278        {
279            if ext.is_barrel_file(&resolved_str) {
280                // Namespace re-export (`export * as Ns from '...'`) changes the symbol
281                // namespace, so the caller's symbol names no longer match the nested
282                // exports. Treat as opaque wildcard by passing empty symbols.
283                let nested_symbols: &[String] = if re_export.namespace_wildcard {
284                    &[]
285                } else {
286                    symbols
287                };
288                resolve_barrel_exports_inner(
289                    ext,
290                    &PathBuf::from(&resolved_str),
291                    nested_symbols,
292                    scan_root,
293                    canonical_root,
294                    visited,
295                    depth + 1,
296                    results,
297                );
298            } else if !ext.is_non_sut_helper(&resolved_str, false) {
299                // Non-barrel path: namespace_wildcard does not change symbols filtering here.
300                // The caller's symbols are used as-is for file_exports_any_symbol check.
301                if !symbols.is_empty()
302                    && re_export.wildcard
303                    && !ext.file_exports_any_symbol(Path::new(&resolved_str), symbols)
304                {
305                    continue;
306                }
307                if let Ok(canonical) = PathBuf::from(&resolved_str).canonicalize() {
308                    if canonical.starts_with(canonical_root) && !results.contains(&canonical) {
309                        results.push(canonical);
310                    }
311                }
312            }
313        }
314    }
315}
316
317/// Helper: given a resolved file path, follow barrel re-exports if needed and
318/// collect matching production-file indices.
319pub fn collect_import_matches(
320    ext: &dyn ObserveExtractor,
321    resolved: &str,
322    symbols: &[String],
323    canonical_to_idx: &HashMap<String, usize>,
324    indices: &mut HashSet<usize>,
325    canonical_root: &Path,
326) {
327    if ext.is_barrel_file(resolved) {
328        let barrel_path = PathBuf::from(resolved);
329        let resolved_files = resolve_barrel_exports(ext, &barrel_path, symbols, canonical_root);
330        for prod in resolved_files {
331            let prod_str = prod.to_string_lossy().into_owned();
332            if !ext.is_non_sut_helper(&prod_str, canonical_to_idx.contains_key(&prod_str)) {
333                if let Some(&idx) = canonical_to_idx.get(&prod_str) {
334                    indices.insert(idx);
335                }
336            }
337        }
338        // Also check if the barrel file itself directly defines/exports the symbols
339        if ext.file_exports_any_symbol(Path::new(resolved), symbols)
340            && !ext.is_non_sut_helper(resolved, canonical_to_idx.contains_key(resolved))
341        {
342            if let Some(&idx) = canonical_to_idx.get(resolved) {
343                indices.insert(idx);
344            }
345        }
346    } else if !ext.is_non_sut_helper(resolved, canonical_to_idx.contains_key(resolved)) {
347        if let Some(&idx) = canonical_to_idx.get(resolved) {
348            indices.insert(idx);
349        }
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Tests
355// ---------------------------------------------------------------------------
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    struct MockExtractor;
362
363    impl ObserveExtractor for MockExtractor {
364        fn extract_production_functions(
365            &self,
366            _source: &str,
367            _file_path: &str,
368        ) -> Vec<ProductionFunction> {
369            vec![]
370        }
371        fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
372            vec![]
373        }
374        fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
375            vec![]
376        }
377        fn extract_barrel_re_exports(
378            &self,
379            _source: &str,
380            _file_path: &str,
381        ) -> Vec<BarrelReExport> {
382            vec![]
383        }
384        fn source_extensions(&self) -> &[&str] {
385            &["ts", "tsx", "js", "jsx"]
386        }
387        fn index_file_names(&self) -> &[&str] {
388            &["index.ts", "index.tsx"]
389        }
390        fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
391            Path::new(path).file_stem()?.to_str()
392        }
393        fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
394            let stem = Path::new(path).file_stem()?.to_str()?;
395            stem.strip_suffix(".spec")
396                .or_else(|| stem.strip_suffix(".test"))
397        }
398        fn is_non_sut_helper(&self, _file_path: &str, _is_known_production: bool) -> bool {
399            false
400        }
401    }
402
403    /// Configurable MockExtractor for CORE-CIM tests.
404    struct ConfigurableMockExtractor {
405        barrel_file_names: Vec<String>,
406        helper_file_paths: Vec<String>,
407    }
408
409    impl ConfigurableMockExtractor {
410        fn new() -> Self {
411            Self {
412                barrel_file_names: vec!["index.ts".to_string()],
413                helper_file_paths: vec![],
414            }
415        }
416
417        fn with_helpers(helper_paths: Vec<String>) -> Self {
418            Self {
419                barrel_file_names: vec!["index.ts".to_string()],
420                helper_file_paths: helper_paths,
421            }
422        }
423    }
424
425    impl ObserveExtractor for ConfigurableMockExtractor {
426        fn extract_production_functions(
427            &self,
428            _source: &str,
429            _file_path: &str,
430        ) -> Vec<ProductionFunction> {
431            vec![]
432        }
433        fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
434            vec![]
435        }
436        fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
437            vec![]
438        }
439        fn extract_barrel_re_exports(
440            &self,
441            _source: &str,
442            _file_path: &str,
443        ) -> Vec<BarrelReExport> {
444            // Returns empty to avoid real fs access; barrel resolution tested separately
445            vec![]
446        }
447        fn source_extensions(&self) -> &[&str] {
448            &["ts", "tsx"]
449        }
450        fn index_file_names(&self) -> &[&str] {
451            // Return a static slice matching our barrel file names
452            &["index.ts"]
453        }
454        fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
455            Path::new(path).file_stem()?.to_str()
456        }
457        fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
458            let stem = Path::new(path).file_stem()?.to_str()?;
459            stem.strip_suffix(".spec")
460                .or_else(|| stem.strip_suffix(".test"))
461        }
462        fn is_non_sut_helper(&self, file_path: &str, _is_known_production: bool) -> bool {
463            self.helper_file_paths.iter().any(|h| h == file_path)
464        }
465    }
466
467    // TC-01: map_test_files で Layer 1 stem matching が動作
468    #[test]
469    fn tc01_map_test_files_stem_matching() {
470        let mock = MockExtractor;
471        let production = vec!["src/user.service.ts".to_string()];
472        let tests = vec!["src/user.service.spec.ts".to_string()];
473        let result = map_test_files(&mock, &production, &tests);
474        assert_eq!(result.len(), 1);
475        assert_eq!(result[0].production_file, "src/user.service.ts");
476        assert_eq!(result[0].test_files, vec!["src/user.service.spec.ts"]);
477        assert_eq!(result[0].strategy, MappingStrategy::FileNameConvention);
478    }
479
480    // TC-01b: map_test_files でマッチしない場合は空
481    #[test]
482    fn tc01b_map_test_files_no_match() {
483        let mock = MockExtractor;
484        let production = vec!["src/user.service.ts".to_string()];
485        let tests = vec!["src/order.service.spec.ts".to_string()];
486        let result = map_test_files(&mock, &production, &tests);
487        assert_eq!(result.len(), 1);
488        assert!(result[0].test_files.is_empty());
489    }
490
491    // TC-03: is_barrel_file が index_file_names で判定
492    #[test]
493    fn tc03_is_barrel_file_default_impl() {
494        let mock = MockExtractor;
495        assert!(mock.is_barrel_file("src/index.ts"));
496        assert!(mock.is_barrel_file("src/index.tsx"));
497        assert!(!mock.is_barrel_file("src/user.service.ts"));
498        assert!(!mock.is_barrel_file("src/index.rs")); // not in mock's index_file_names
499    }
500
501    // TC-06: Send + Sync bound
502    #[test]
503    fn tc06_trait_is_send_sync() {
504        fn assert_send_sync<T: Send + Sync>() {}
505        assert_send_sync::<MockExtractor>();
506        // Box<dyn ObserveExtractor> should also work
507        let _: Box<dyn ObserveExtractor + Send + Sync> = Box::new(MockExtractor);
508    }
509
510    // CORE-CIM-01: barrel file 経由の production match
511    //
512    // Given: is_barrel_file returns true (path ends in index.ts), but resolve_barrel_exports
513    //        returns no files (in-memory extractor avoids real fs access). The barrel path itself
514    //        is also present in canonical_to_idx as a fallback production entry.
515    //        file_exports_any_symbol returns true (default).
516    // When:  collect_import_matches is called with the barrel path
517    // Then:  barrel self-match fix: barrel path itself is added when file_exports_any_symbol=true,
518    //        NOT via the else-if direct-match branch, but via the barrel branch self-match check.
519    #[test]
520    fn core_cim_01_barrel_file_skips_direct_match_branch() {
521        // Given
522        let ext = ConfigurableMockExtractor::new();
523        let barrel_path = "/project/src/index.ts";
524        let symbols: Vec<String> = vec!["UserService".to_string()];
525        let canonical_root = Path::new("/project/src");
526
527        // canonical_to_idx contains the barrel path itself
528        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
529        canonical_to_idx.insert(barrel_path.to_string(), 0);
530        let mut indices: HashSet<usize> = HashSet::new();
531
532        // When: barrel file — resolve_barrel_exports returns empty (no real fs).
533        //       file_exports_any_symbol returns true (ConfigurableMockExtractor default).
534        collect_import_matches(
535            &ext,
536            barrel_path,
537            &symbols,
538            &canonical_to_idx,
539            &mut indices,
540            canonical_root,
541        );
542
543        // Then: barrel branch was taken (not the else-if direct-match branch).
544        //       Because file_exports_any_symbol=true, barrel self-match adds index 0.
545        assert!(
546            indices.contains(&0),
547            "barrel path (file_exports_any_symbol=true) must be added via barrel \
548             self-match check, not via direct-match branch. Got indices: {:?}",
549            indices
550        );
551    }
552
553    // CORE-CIM-02: 非 barrel file の直接 match
554    //
555    // Given: is_barrel_file returns false, is_non_sut_helper returns false,
556    //        production file exists in canonical_to_idx at index 0
557    // When: collect_import_matches is called with the production file path
558    // Then: index 0 is inserted into indices
559    #[test]
560    fn core_cim_02_non_barrel_direct_match() {
561        // Given
562        let ext = ConfigurableMockExtractor::new();
563        let prod_path = "/project/src/user.service.ts";
564        let symbols: Vec<String> = vec!["UserService".to_string()];
565        let canonical_root = Path::new("/project/src");
566
567        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
568        canonical_to_idx.insert(prod_path.to_string(), 0);
569        let mut indices: HashSet<usize> = HashSet::new();
570
571        // When
572        collect_import_matches(
573            &ext,
574            prod_path,
575            &symbols,
576            &canonical_to_idx,
577            &mut indices,
578            canonical_root,
579        );
580
581        // Then
582        assert!(
583            indices.contains(&0),
584            "production file index must be inserted for non-barrel direct match"
585        );
586        assert_eq!(indices.len(), 1);
587    }
588
589    // CORE-CIM-03: helper file はスキップ
590    //
591    // Given: is_non_sut_helper returns true for the resolved path
592    // When: collect_import_matches is called
593    // Then: indices stays empty
594    #[test]
595    fn core_cim_03_helper_file_skipped() {
596        // Given
597        let helper_path = "/project/src/test-utils.ts";
598        let ext = ConfigurableMockExtractor::with_helpers(vec![helper_path.to_string()]);
599        let symbols: Vec<String> = vec![];
600        let canonical_root = Path::new("/project/src");
601
602        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
603        canonical_to_idx.insert(helper_path.to_string(), 0);
604        let mut indices: HashSet<usize> = HashSet::new();
605
606        // When
607        collect_import_matches(
608            &ext,
609            helper_path,
610            &symbols,
611            &canonical_to_idx,
612            &mut indices,
613            canonical_root,
614        );
615
616        // Then
617        assert!(
618            indices.is_empty(),
619            "helper files must be skipped and not added to indices"
620        );
621    }
622
623    // ---------------------------------------------------------------------------
624    // Barrel self-match tests (TC-01 through TC-06 from cycle 20260325_2303)
625    // ---------------------------------------------------------------------------
626
627    /// MockExtractor that treats `mod.rs` as a barrel file and exposes
628    /// `file_exports_any_symbol` as a configurable flag.
629    struct BarrelSelfMatchMock {
630        exports_symbol: bool,
631    }
632
633    impl ObserveExtractor for BarrelSelfMatchMock {
634        fn extract_production_functions(
635            &self,
636            _source: &str,
637            _file_path: &str,
638        ) -> Vec<ProductionFunction> {
639            vec![]
640        }
641        fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
642            vec![]
643        }
644        fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
645            vec![]
646        }
647        fn extract_barrel_re_exports(
648            &self,
649            _source: &str,
650            _file_path: &str,
651        ) -> Vec<BarrelReExport> {
652            vec![]
653        }
654        fn source_extensions(&self) -> &[&'static str] {
655            &["rs"]
656        }
657        fn index_file_names(&self) -> &[&'static str] {
658            &["mod.rs"]
659        }
660        fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
661            Path::new(path).file_stem()?.to_str()
662        }
663        fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
664            let stem = Path::new(path).file_stem()?.to_str()?;
665            stem.strip_suffix("_test")
666        }
667        fn is_non_sut_helper(&self, _file_path: &str, _is_known_production: bool) -> bool {
668            false
669        }
670        fn file_exports_any_symbol(&self, _path: &Path, _symbols: &[String]) -> bool {
671            self.exports_symbol
672        }
673    }
674
675    // TC-01: barrel file (mod.rs) がシンボルを直接定義する場合、mod.rs 自体が candidate に含まれること
676    //
677    // Given: mod.rs は barrel file (is_barrel_file=true)
678    //        file_exports_any_symbol=true (mod.rs に `pub struct Foo` が直接定義されている)
679    //        canonical_to_idx に mod.rs が index 0 で登録されている
680    // When:  test imports `use crate::module::Foo` -> collect_import_matches(mod.rs, ["Foo"], ...)
681    // Then:  index 0 が indices に追加される (mod.rs が production candidate)
682    //
683    // RED state: collect_import_matches の barrel 分岐が mod.rs 自体を除外するため FAIL する
684    #[test]
685    fn tc01_barrel_self_match_when_exports_symbol_directly() {
686        // Given
687        let ext = BarrelSelfMatchMock {
688            exports_symbol: true,
689        };
690        let barrel_path = "/project/src/filter/mod.rs";
691        let symbols: Vec<String> = vec!["Foo".to_string()];
692        let canonical_root = Path::new("/project/src");
693
694        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
695        canonical_to_idx.insert(barrel_path.to_string(), 0);
696        let mut indices: HashSet<usize> = HashSet::new();
697
698        // When
699        collect_import_matches(
700            &ext,
701            barrel_path,
702            &symbols,
703            &canonical_to_idx,
704            &mut indices,
705            canonical_root,
706        );
707
708        // Then: mod.rs 自体が candidate に含まれる
709        assert!(
710            indices.contains(&0),
711            "barrel file (mod.rs) that directly defines the imported symbol \
712             must be added as a production candidate (index 0). \
713             Got indices: {:?}",
714            indices
715        );
716    }
717
718    // TC-02: barrel file (mod.rs) がシンボルを直接定義しない場合、mod.rs 自体は candidate に含まれないこと
719    //
720    // Given: mod.rs は barrel file (is_barrel_file=true)
721    //        file_exports_any_symbol=false (mod.rs にシンボル定義なし、子ファイルのみ re-export)
722    //        canonical_to_idx に mod.rs が index 0 で登録されている
723    // When:  collect_import_matches(mod.rs, ["Foo"], ...)
724    // Then:  indices は空のまま (mod.rs は candidate に含まれない)
725    //
726    // regression guard: 修正後もこの動作が維持されること
727    #[test]
728    fn tc02_barrel_no_self_match_when_symbol_not_defined_directly() {
729        // Given
730        let ext = BarrelSelfMatchMock {
731            exports_symbol: false,
732        };
733        let barrel_path = "/project/src/filter/mod.rs";
734        let symbols: Vec<String> = vec!["Foo".to_string()];
735        let canonical_root = Path::new("/project/src");
736
737        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
738        canonical_to_idx.insert(barrel_path.to_string(), 0);
739        let mut indices: HashSet<usize> = HashSet::new();
740
741        // When
742        collect_import_matches(
743            &ext,
744            barrel_path,
745            &symbols,
746            &canonical_to_idx,
747            &mut indices,
748            canonical_root,
749        );
750
751        // Then: mod.rs は candidate に含まれない
752        assert!(
753            indices.is_empty(),
754            "barrel file (mod.rs) that does NOT directly define the symbol \
755             must NOT be added as a production candidate. \
756             Got indices: {:?}",
757            indices
758        );
759    }
760
761    // TC-03: TS index.ts barrel — exports_symbol=true の場合 index.ts 自体が candidate に含まれること
762    //
763    // Given: index.ts は barrel file (is_barrel_file=true)
764    //        file_exports_any_symbol=true (デフォルト: ConfigurableMockExtractor)
765    //        canonical_to_idx に index.ts が index 0 で登録されている
766    //        resolve_barrel_exports は空を返す (fs アクセスなし)
767    // When:  collect_import_matches(index.ts, ["UserService"], ...)
768    // Then:  修正後は index.ts が candidate に含まれること (TS barrel self-match)
769    //
770    // RED state: 修正前は FAIL する (TC-01 と同じ根本原因)
771    #[test]
772    fn tc03_ts_index_barrel_self_match_regression_guard() {
773        // Given: ConfigurableMockExtractor (index_file_names=["index.ts"], file_exports_any_symbol=true by default)
774        let ext = ConfigurableMockExtractor::new();
775        let barrel_path = "/project/src/services/index.ts";
776        let symbols: Vec<String> = vec!["UserService".to_string()];
777        let canonical_root = Path::new("/project/src");
778
779        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
780        canonical_to_idx.insert(barrel_path.to_string(), 0);
781        let mut indices: HashSet<usize> = HashSet::new();
782
783        // When
784        collect_import_matches(
785            &ext,
786            barrel_path,
787            &symbols,
788            &canonical_to_idx,
789            &mut indices,
790            canonical_root,
791        );
792
793        // Then: index.ts が直接シンボルを定義している場合、candidate に含まれること
794        assert!(
795            indices.contains(&0),
796            "TS index.ts barrel (file_exports_any_symbol=true) \
797             must be added as a production candidate after barrel self-match fix. \
798             Got indices: {:?}",
799            indices
800        );
801    }
802
803    // TC-04: Python __init__.py barrel — exports_symbol=true の場合 __init__.py 自体が candidate に含まれること
804    //
805    // Given: __init__.py は barrel file (is_barrel_file=true)
806    //        file_exports_any_symbol=true (デフォルト)
807    //        canonical_to_idx に __init__.py が index 0 で登録されている
808    // When:  collect_import_matches(__init__.py, ["Foo"], ...)
809    // Then:  修正後は __init__.py が candidate に含まれること
810    //
811    // RED state: 修正前は FAIL する (TC-01/TC-03 と同じ根本原因)
812    #[test]
813    fn tc04_python_init_barrel_self_match_regression_guard() {
814        struct PythonBarrelMock;
815
816        impl ObserveExtractor for PythonBarrelMock {
817            fn extract_production_functions(
818                &self,
819                _source: &str,
820                _file_path: &str,
821            ) -> Vec<ProductionFunction> {
822                vec![]
823            }
824            fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
825                vec![]
826            }
827            fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
828                vec![]
829            }
830            fn extract_barrel_re_exports(
831                &self,
832                _source: &str,
833                _file_path: &str,
834            ) -> Vec<BarrelReExport> {
835                vec![]
836            }
837            fn source_extensions(&self) -> &[&'static str] {
838                &["py"]
839            }
840            fn index_file_names(&self) -> &[&'static str] {
841                &["__init__.py"]
842            }
843            fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
844                Path::new(path).file_stem()?.to_str()
845            }
846            fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
847                let stem = Path::new(path).file_stem()?.to_str()?;
848                stem.strip_prefix("test_")
849            }
850            fn is_non_sut_helper(&self, _file_path: &str, _is_known_production: bool) -> bool {
851                false
852            }
853            // file_exports_any_symbol: default = true
854        }
855
856        // Given
857        let ext = PythonBarrelMock;
858        let barrel_path = "/project/pkg/__init__.py";
859        let symbols: Vec<String> = vec!["Foo".to_string()];
860        let canonical_root = Path::new("/project/pkg");
861
862        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
863        canonical_to_idx.insert(barrel_path.to_string(), 0);
864        let mut indices: HashSet<usize> = HashSet::new();
865
866        // When
867        collect_import_matches(
868            &ext,
869            barrel_path,
870            &symbols,
871            &canonical_to_idx,
872            &mut indices,
873            canonical_root,
874        );
875
876        // Then: __init__.py が直接シンボルを定義している場合、candidate に含まれること
877        assert!(
878            indices.contains(&0),
879            "Python __init__.py barrel (file_exports_any_symbol=true) \
880             must be added as a production candidate after barrel self-match fix. \
881             Got indices: {:?}",
882            indices
883        );
884    }
885
886    // TC-05: tower observe R > 78.3% after barrel self-match fix (integration test)
887    //
888    // Given: tower crate at /tmp/exspec-dogfood/tower
889    // When:  observe runs with Rust extractor after fix
890    // Then:  recall > 78.3% (improvement from mod.rs barrel FN being resolved)
891    #[test]
892    #[ignore = "requires /tmp/exspec-dogfood/tower to be present"]
893    fn tc05_tower_observe_recall_improves_after_barrel_self_match() {
894        // Evidence collection:
895        //   cargo run -- observe --lang rust --format json /tmp/exspec-dogfood/tower
896        // Expected: mapped test files / total test files > 0.783
897        panic!(
898            "TC-05: integration test. \
899             Run: cargo run -- observe --lang rust --format json /tmp/exspec-dogfood/tower \
900             and verify recall > 78.3%"
901        );
902    }
903
904    // TC-06: tokio observe R >= 50.8% after fix (no regression)
905    //
906    // Given: tokio crate at /tmp/exspec-dogfood/tokio
907    // When:  observe runs with Rust extractor after fix
908    // Then:  recall >= 50.8% (no regression from baseline)
909    #[test]
910    #[ignore = "requires /tmp/exspec-dogfood/tokio to be present"]
911    fn tc06_tokio_observe_no_regression_after_barrel_self_match() {
912        // Evidence collection:
913        //   cargo run -- observe --lang rust --format json /tmp/exspec-dogfood/tokio
914        // Expected: mapped test files / total test files >= 0.508
915        panic!(
916            "TC-06: integration test. \
917             Run: cargo run -- observe --lang rust --format json /tmp/exspec-dogfood/tokio \
918             and verify recall >= 50.8%"
919        );
920    }
921
922    // CORE-CIM-04: canonical_to_idx に存在しない file はスキップ
923    //
924    // Given: canonical_to_idx is empty
925    // When: collect_import_matches is called with any non-barrel, non-helper path
926    // Then: indices stays empty
927    #[test]
928    fn core_cim_04_unknown_file_skipped() {
929        // Given
930        let ext = ConfigurableMockExtractor::new();
931        let unknown_path = "/project/src/unknown.service.ts";
932        let symbols: Vec<String> = vec![];
933        let canonical_root = Path::new("/project/src");
934
935        let canonical_to_idx: HashMap<String, usize> = HashMap::new(); // empty
936        let mut indices: HashSet<usize> = HashSet::new();
937
938        // When
939        collect_import_matches(
940            &ext,
941            unknown_path,
942            &symbols,
943            &canonical_to_idx,
944            &mut indices,
945            canonical_root,
946        );
947
948        // Then
949        assert!(
950            indices.is_empty(),
951            "file not in canonical_to_idx must be skipped"
952        );
953    }
954}