Skip to main content

fallow_engine/
module_graph.rs

1//! Module graph contracts owned by the engine boundary.
2
3#![allow(
4    clippy::implicit_hasher,
5    reason = "engine graph helpers use FxHashSet changed-file sets consistently with the rest of fallow"
6)]
7
8use std::path::{Path, PathBuf};
9
10use fallow_output::TestAdjacency;
11use fallow_types::discover::FileId;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use fallow_graph::graph::{
15    CoordinationGapPaths as GraphCoordinationGapPaths,
16    FocusFileFactsPaths as GraphFocusFileFactsPaths, ImpactClosurePaths as GraphImpactClosurePaths,
17    ModuleGraph, PartitionOrderPaths as GraphPartitionOrderPaths,
18    ReviewUnitPaths as GraphReviewUnitPaths,
19};
20use fallow_graph::graph::{
21    DirectImporterSummary as GraphDirectImporterSummary,
22    ImportedSymbolSummary as GraphImportedSymbolSummary,
23};
24
25/// Engine-owned retained graph handle.
26///
27/// Downstream crates can request stable graph facts through engine helpers
28/// without depending on `fallow-graph` node internals.
29#[derive(Debug)]
30pub struct RetainedModuleGraph {
31    inner: ModuleGraph,
32}
33
34impl RetainedModuleGraph {
35    /// Wrap a freshly built module graph for engine result contracts.
36    #[must_use]
37    const fn new(inner: ModuleGraph) -> Self {
38        Self { inner }
39    }
40
41    pub(crate) const fn as_graph(&self) -> &ModuleGraph {
42        &self.inner
43    }
44
45    /// Borrow the shared static test-coverage view for health analysis.
46    pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
47        StaticTestCoverage::new(&self.inner)
48    }
49
50    /// Number of modules in the retained graph.
51    #[must_use]
52    pub fn module_count(&self) -> usize {
53        self.inner.module_count()
54    }
55
56    /// Number of edges in the retained graph.
57    #[must_use]
58    pub fn edge_count(&self) -> usize {
59        self.inner.edge_count()
60    }
61
62    /// Build public export keys for a precomputed public-entry set.
63    #[must_use]
64    pub(crate) fn public_export_keys(
65        &self,
66        public_entries: &FxHashSet<FileId>,
67        root: &Path,
68    ) -> FxHashSet<String> {
69        self.inner.public_export_keys(public_entries, root)
70    }
71
72    /// Count direct importer modules for one file id.
73    #[must_use]
74    pub fn direct_importer_count(&self, file_id: FileId) -> usize {
75        self.inner
76            .reverse_deps
77            .get(file_id.0 as usize)
78            .map_or(0, Vec::len)
79    }
80
81    /// Summaries for modules that directly import one file.
82    #[must_use]
83    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
84        self.inner
85            .direct_importer_summaries(target)
86            .into_iter()
87            .map(DirectImporterSummary::from)
88            .collect()
89    }
90}
91
92/// Engine-owned view of root-correlated static test reachability.
93///
94/// This keeps health consumers on one contract while the graph crate owns the
95/// traversal and replacement-mask representation.
96#[derive(Clone, Copy)]
97pub(crate) struct StaticTestCoverage<'a> {
98    graph: &'a ModuleGraph,
99}
100
101impl<'a> StaticTestCoverage<'a> {
102    pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
103        Self { graph }
104    }
105
106    pub(crate) fn covers_file(self, file_id: FileId) -> bool {
107        self.graph.is_test_reachable(file_id)
108    }
109
110    pub(crate) fn covers_any_reference(self, export: &fallow_graph::graph::ExportSymbol) -> bool {
111        self.graph.is_any_test_reference_covered(export)
112    }
113}
114
115impl From<ModuleGraph> for RetainedModuleGraph {
116    fn from(inner: ModuleGraph) -> Self {
117        Self::new(inner)
118    }
119}
120
121/// Engine-owned importer details for one file that directly imports a target module.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct DirectImporterSummary {
124    /// File containing the import statement.
125    pub source: FileId,
126    /// Symbols this importer pulls from the target module.
127    pub symbols: Vec<ImportedSymbolSummary>,
128}
129
130impl From<GraphDirectImporterSummary> for DirectImporterSummary {
131    fn from(summary: GraphDirectImporterSummary) -> Self {
132        Self {
133            source: summary.source,
134            symbols: summary.symbols.into_iter().map(Into::into).collect(),
135        }
136    }
137}
138
139/// Engine-owned symbol details for a direct import edge.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ImportedSymbolSummary {
142    /// Exported name as declared by the target module.
143    pub imported: String,
144    /// Local binding name at the import site.
145    pub local: String,
146    /// True for type-only imports, which do not count as value usage.
147    pub type_only: bool,
148}
149
150impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
151    fn from(symbol: GraphImportedSymbolSummary) -> Self {
152        Self {
153            imported: symbol.imported,
154            local: symbol.local,
155            type_only: symbol.type_only,
156        }
157    }
158}
159
160/// Engine-owned snapshot of one value export in a module graph.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ModuleValueExport {
163    /// File declaring the export.
164    pub file_id: FileId,
165    /// Exported name.
166    pub name: String,
167    /// Byte offset where the export declaration starts.
168    pub span_start: u32,
169    /// True when a test file references this export.
170    pub test_referenced: bool,
171}
172
173/// Engine-owned impact closure with file ids resolved to paths.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct ImpactClosurePaths {
176    /// Changed files that are part of the diff under review.
177    pub in_diff: Vec<String>,
178    /// Files affected through the import closure but absent from the diff.
179    pub affected_not_shown: Vec<String>,
180    /// Changed contracts whose consumers were not updated in the same diff.
181    pub coordination_gap: Vec<CoordinationGapPaths>,
182}
183
184impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
185    fn from(paths: GraphImpactClosurePaths) -> Self {
186        Self {
187            in_diff: paths.in_diff,
188            affected_not_shown: paths.affected_not_shown,
189            coordination_gap: paths
190                .coordination_gap
191                .into_iter()
192                .map(CoordinationGapPaths::from)
193                .collect(),
194        }
195    }
196}
197
198/// Engine-owned coordination gap between a changed contract and consumer.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct CoordinationGapPaths {
201    /// Path of the changed file whose contract moved.
202    pub changed_file: String,
203    /// Path of the consumer that was not updated alongside it.
204    pub consumer_file: String,
205    /// Symbols the consumer imports from the changed file.
206    pub consumed_symbols: Vec<String>,
207}
208
209impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
210    fn from(paths: GraphCoordinationGapPaths) -> Self {
211        Self {
212            changed_file: paths.changed_file,
213            consumer_file: paths.consumer_file,
214            consumed_symbols: paths.consumed_symbols,
215        }
216    }
217}
218
219/// Engine-owned review partition and dependency-sensible order.
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub struct PartitionOrderPaths {
222    /// Changed files partitioned into per-module review units.
223    pub units: Vec<ReviewUnitPaths>,
224    /// File paths in dependency-sensible review order (dependencies first).
225    pub order: Vec<String>,
226    /// Connected components of the inter-unit dependency graph: units that
227    /// share no import edge with any unit outside their slice.
228    pub independent_slices: Vec<Vec<String>>,
229}
230
231impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
232    fn from(paths: GraphPartitionOrderPaths) -> Self {
233        Self {
234            units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
235            order: paths.order,
236            independent_slices: paths.independent_slices,
237        }
238    }
239}
240
241/// Engine-owned changed-file review unit.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ReviewUnitPaths {
244    /// Directory the unit's files share.
245    pub module_dir: String,
246    /// Changed files grouped into this unit.
247    pub files: Vec<String>,
248}
249
250impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
251    fn from(paths: GraphReviewUnitPaths) -> Self {
252        Self {
253            module_dir: paths.module_dir,
254            files: paths.files,
255        }
256    }
257}
258
259/// Engine-owned focus facts for one changed file.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct FocusFileFactsPaths {
262    /// Path of the changed file the facts describe.
263    pub file: String,
264    /// Count of distinct files importing this file (blast radius), excluding
265    /// the file itself.
266    pub fan_in: u32,
267    /// Count of distinct files this file imports, excluding the file itself.
268    pub fan_out: u32,
269    /// True when the file has a dynamic-import edge in either direction, so
270    /// its static reachability signal is not complete. Conservative: a file
271    /// that MAY be dynamically wired carries the flag.
272    pub dynamic_dispatch: bool,
273    /// True when the file's reachability runs through re-export indirection,
274    /// so direct importer counts understate its reach.
275    pub re_export_indirection: bool,
276}
277
278impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
279    fn from(paths: GraphFocusFileFactsPaths) -> Self {
280        Self {
281            file: paths.file,
282            fan_in: paths.fan_in,
283            fan_out: paths.fan_out,
284            dynamic_dispatch: paths.dynamic_dispatch,
285            re_export_indirection: paths.re_export_indirection,
286        }
287    }
288}
289
290/// Return value exports with test-reference state without exposing graph node
291/// internals to downstream crates.
292#[must_use]
293pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
294    let test_coverage = graph.static_test_coverage();
295    let graph = graph.as_graph();
296
297    graph
298        .modules
299        .iter()
300        .flat_map(|node| {
301            node.exports
302                .iter()
303                .filter(|export| !export.is_type_only)
304                .map(|export| ModuleValueExport {
305                    file_id: node.file_id,
306                    name: export.name.to_string(),
307                    span_start: export.span.start,
308                    test_referenced: test_coverage.covers_any_reference(export),
309                })
310        })
311        .collect()
312}
313
314/// Compute a path-resolved impact closure for absolute changed paths.
315#[must_use]
316pub fn impact_closure_for_changed_paths(
317    graph: &RetainedModuleGraph,
318    root: &Path,
319    changed_files: &FxHashSet<PathBuf>,
320) -> Option<ImpactClosurePaths> {
321    let graph = graph.as_graph();
322    let changed_ids = changed_file_ids(graph, changed_files);
323    if changed_ids.is_empty() {
324        return None;
325    }
326
327    let closure = graph.impact_closure(&changed_ids);
328    Some(graph.closure_with_paths(&closure, root).into())
329}
330
331/// Compute path-resolved partition order for absolute changed paths.
332#[must_use]
333pub fn partition_order_for_changed_paths(
334    graph: &RetainedModuleGraph,
335    root: &Path,
336    changed_files: &FxHashSet<PathBuf>,
337) -> Option<PartitionOrderPaths> {
338    let graph = graph.as_graph();
339    let changed_ids = changed_file_ids(graph, changed_files);
340    if changed_ids.is_empty() {
341        return None;
342    }
343
344    let partition = graph.partition_order(&changed_ids);
345    Some(graph.partition_order_with_paths(&partition, root).into())
346}
347
348/// Compute path-resolved focus graph facts for absolute changed paths.
349#[must_use]
350pub fn focus_facts_for_changed_paths(
351    graph: &RetainedModuleGraph,
352    root: &Path,
353    changed_files: &FxHashSet<PathBuf>,
354) -> Option<Vec<FocusFileFactsPaths>> {
355    let graph = graph.as_graph();
356    let changed_ids = changed_file_ids(graph, changed_files);
357    if changed_ids.is_empty() {
358        return None;
359    }
360
361    let facts = graph.focus_file_facts(&changed_ids);
362    Some(
363        graph
364            .focus_facts_with_paths(&facts, root)
365            .into_iter()
366            .map(FocusFileFactsPaths::from)
367            .collect(),
368    )
369}
370
371/// Compute changed-file export line anchors without exposing graph nodes.
372#[must_use]
373pub fn export_lines_for_changed_paths(
374    graph: &RetainedModuleGraph,
375    root: &Path,
376    changed_files: &FxHashSet<PathBuf>,
377) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
378    let graph = graph.as_graph();
379    let changed_norm = normalized_changed_paths(changed_files);
380    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
381    for module in &graph.modules {
382        let abs = normalize_path(&module.path);
383        if !changed_norm.contains(&abs) || module.exports.is_empty() {
384            continue;
385        }
386        let Ok(content) = std::fs::read_to_string(&module.path) else {
387            continue;
388        };
389        let offsets = fallow_types::extract::compute_line_offsets(&content);
390        let exports: Vec<(String, u32)> = module
391            .exports
392            .iter()
393            .map(|export| {
394                let (line, _) =
395                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
396                (export.name.to_string(), line)
397            })
398            .collect();
399        map.insert(relative_key_path(&module.path, root), exports);
400    }
401    Some(map)
402}
403
404/// Compute direct non-diff internal consumer counts for absolute changed paths.
405#[must_use]
406pub fn internal_consumers_for_changed_paths(
407    graph: &RetainedModuleGraph,
408    root: &Path,
409    changed_files: &FxHashSet<PathBuf>,
410) -> Option<FxHashMap<String, u64>> {
411    let graph = graph.as_graph();
412    let changed_norm = normalized_changed_paths(changed_files);
413    let id_to_norm: FxHashMap<FileId, String> = graph
414        .modules
415        .iter()
416        .map(|module| (module.file_id, normalize_path(&module.path)))
417        .collect();
418
419    let mut map: FxHashMap<String, u64> = FxHashMap::default();
420    for module in &graph.modules {
421        let abs = normalize_path(&module.path);
422        if !changed_norm.contains(&abs) {
423            continue;
424        }
425        let count = graph
426            .importers_of(module.file_id)
427            .iter()
428            .filter(|imp| {
429                id_to_norm
430                    .get(imp)
431                    .is_none_or(|p| !changed_norm.contains(p))
432            })
433            .count() as u64;
434        map.insert(relative_key_path(&module.path, root), count);
435    }
436    Some(map)
437}
438
439/// Compute per-changed-file test adjacency for the review direction: whether a
440/// test file imports the changed module directly, and whether one of those tests
441/// is itself in the changed set. `is_test_path` classifies a root-relative,
442/// forward-slashed path; the caller owns that definition so the brief's
443/// weakening and direction surfaces agree on it. Changed test files get no
444/// entry of their own. `None` when no module matches a changed path.
445#[must_use]
446pub fn test_adjacency_for_changed_paths(
447    graph: &RetainedModuleGraph,
448    root: &Path,
449    changed_files: &FxHashSet<PathBuf>,
450    is_test_path: &dyn Fn(&str) -> bool,
451) -> Option<FxHashMap<String, TestAdjacency>> {
452    let graph = graph.as_graph();
453    // A project with no test files at all gets no adjacency claims: "no direct
454    // test" on every unit would say nothing about the change.
455    if !graph
456        .modules
457        .iter()
458        .any(|module| is_test_path(&relative_key_path(&module.path, root)))
459    {
460        return None;
461    }
462    let changed_norm = normalized_changed_paths(changed_files);
463    let mut map: FxHashMap<String, TestAdjacency> = FxHashMap::default();
464    for module in &graph.modules {
465        if !changed_norm.contains(&normalize_path(&module.path)) {
466            continue;
467        }
468        let rel = relative_key_path(&module.path, root);
469        if is_test_path(&rel) {
470            continue;
471        }
472        let mut adjacency = TestAdjacency::None;
473        for importer in graph.importers_of(module.file_id) {
474            let Some(node) = graph.modules.get(importer.0 as usize) else {
475                continue;
476            };
477            if !is_test_path(&relative_key_path(&node.path, root)) {
478                continue;
479            }
480            if changed_norm.contains(&normalize_path(&node.path)) {
481                adjacency = TestAdjacency::Changed;
482                break;
483            }
484            adjacency = TestAdjacency::Untouched;
485        }
486        map.insert(rel, adjacency);
487    }
488    if map.is_empty() {
489        return None;
490    }
491    Some(map)
492}
493
494/// In-repo importer counts for one third-party package.
495#[derive(Debug, Clone, Default, PartialEq, Eq)]
496pub struct PackageImporters {
497    /// Modules that import the package (value imports only), id-sorted. Kept
498    /// as ids rather than a count so a decision that batches several packages
499    /// can take the union instead of double-counting a module that imports
500    /// more than one of them.
501    pub importers: Vec<FileId>,
502    /// The subset of `importers` outside the changed set, id-sorted.
503    pub out_of_diff: Vec<FileId>,
504}
505
506/// Compute per-package in-repo importer counts for every third-party package
507/// the graph saw an import of, value or type-only, split by whether the
508/// importer is in the changed set. Type-only importers count because a major
509/// bump of a types package is a compile-wide change even when no value
510/// crosses the boundary. The dependency decision arm reads these so a new or
511/// bumped `package.json` entry carries the modules it actually reaches. `None`
512/// when the graph recorded no package usage.
513#[must_use]
514pub fn package_importers_for_changed_paths(
515    graph: &RetainedModuleGraph,
516    changed_files: &FxHashSet<PathBuf>,
517) -> Option<FxHashMap<String, PackageImporters>> {
518    let graph = graph.as_graph();
519    if graph.package_usage.is_empty() && graph.type_only_package_usage.is_empty() {
520        return None;
521    }
522    let changed_norm = normalized_changed_paths(changed_files);
523    let id_to_norm: FxHashMap<FileId, String> = graph
524        .modules
525        .iter()
526        .map(|module| (module.file_id, normalize_path(&module.path)))
527        .collect();
528    let mut usage: FxHashMap<&str, Vec<FileId>> = FxHashMap::default();
529    for (package, files) in graph
530        .package_usage
531        .iter()
532        .chain(graph.type_only_package_usage.iter())
533    {
534        usage
535            .entry(package.as_str())
536            .or_default()
537            .extend(files.iter().copied());
538    }
539    let map = usage
540        .into_iter()
541        .map(|(package, files)| {
542            let mut importers: Vec<FileId> = files;
543            importers.sort_unstable_by_key(|id| id.0);
544            importers.dedup();
545            let out_of_diff: Vec<FileId> = importers
546                .iter()
547                .copied()
548                .filter(|id| id_to_norm.get(id).is_none_or(|p| !changed_norm.contains(p)))
549                .collect();
550            (
551                package.to_string(),
552                PackageImporters {
553                    importers,
554                    out_of_diff,
555                },
556            )
557        })
558        .collect();
559    Some(map)
560}
561
562fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
563    let path_to_id: FxHashMap<String, FileId> = graph
564        .modules
565        .iter()
566        .map(|module| (normalize_path(&module.path), module.file_id))
567        .collect();
568
569    changed_files
570        .iter()
571        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
572        .collect()
573}
574
575fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
576    changed_files
577        .iter()
578        .map(|path| normalize_path(path))
579        .collect()
580}
581
582fn normalize_path(path: &Path) -> String {
583    path.to_string_lossy().replace('\\', "/")
584}
585
586fn relative_key_path(path: &Path, root: &Path) -> String {
587    let simple_path = dunce::simplified(path);
588    let simple_root = dunce::simplified(root);
589    simple_path
590        .strip_prefix(simple_root)
591        .unwrap_or(simple_path)
592        .to_string_lossy()
593        .replace('\\', "/")
594}
595
596#[cfg(test)]
597mod tests {
598    use super::{RetainedModuleGraph, module_value_exports};
599    use fallow_graph::graph::ModuleGraph;
600    use fallow_graph::resolve::{
601        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
602    };
603    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
604    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
605    use std::path::PathBuf;
606
607    fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
608        import_with_mechanism(target, imported_name, false)
609    }
610
611    fn import_with_mechanism(
612        target: FileId,
613        imported_name: ImportedName,
614        commonjs: bool,
615    ) -> ResolvedImport {
616        ResolvedImport {
617            info: ImportInfo {
618                source: "./target".to_string(),
619                imported_name,
620                local_name: "target".to_string(),
621                is_type_only: false,
622                is_type_only_star: false,
623                from_style: false,
624                span: oxc_span::Span::new(0, 10),
625                source_span: oxc_span::Span::default(),
626            },
627            target: if commonjs {
628                ResolveResult::CommonJsInternalModule(target)
629            } else {
630                ResolveResult::InternalModule(target)
631            },
632        }
633    }
634
635    fn value_export(name: &str, span_start: u32) -> ExportInfo {
636        ExportInfo {
637            name: ExportName::Named(name.to_string()),
638            local_name: Some(name.to_string()),
639            is_type_only: false,
640            visibility: VisibilityTag::None,
641            expected_unused_reason: None,
642            span: oxc_span::Span::new(span_start, span_start + 10),
643            members: Vec::new(),
644            is_side_effect_used: false,
645            super_class: None,
646        }
647    }
648
649    fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
650        let files: Vec<_> = (0..3)
651            .map(|id| DiscoveredFile {
652                id: FileId(id),
653                path: PathBuf::from(format!("/project/file{id}.ts")),
654                size_bytes: 1,
655            })
656            .collect();
657        let modules = vec![
658            ResolvedModule {
659                file_id: FileId(0),
660                path: files[0].path.clone(),
661                resolved_imports: vec![import(
662                    FileId(2),
663                    ImportedName::Named("target".to_string()),
664                )],
665                ..ResolvedModule::default()
666            },
667            ResolvedModule {
668                file_id: FileId(1),
669                path: files[1].path.clone(),
670                resolved_imports: vec![import(
671                    FileId(2),
672                    if unmasked_root_imports_export {
673                        ImportedName::Named("target".to_string())
674                    } else {
675                        ImportedName::SideEffect
676                    },
677                )],
678                ..ResolvedModule::default()
679            },
680            ResolvedModule {
681                file_id: FileId(2),
682                path: files[2].path.clone(),
683                exports: vec![value_export("target", 0)].into(),
684                ..ResolvedModule::default()
685            },
686        ];
687        let test_entry_points = vec![
688            EntryPoint {
689                path: files[0].path.clone(),
690                source: EntryPointSource::TestFile,
691            },
692            EntryPoint {
693                path: files[1].path.clone(),
694                source: EntryPointSource::TestFile,
695            },
696        ];
697        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
698            &modules,
699            &[ResolvedReplacedModuleTarget {
700                source_file: FileId(0),
701                target_file: FileId(2),
702            }],
703            &test_entry_points,
704            &[],
705            &test_entry_points,
706            &files,
707        );
708        RetainedModuleGraph::from(graph)
709    }
710
711    #[test]
712    fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
713        let graph = mixed_root_graph(false);
714
715        let exports = module_value_exports(&graph);
716
717        assert_eq!(exports.len(), 1);
718        assert!(!exports[0].test_referenced);
719    }
720
721    #[test]
722    fn export_coverage_accepts_an_unmasked_correlated_reference() {
723        let graph = mixed_root_graph(true);
724
725        let exports = module_value_exports(&graph);
726
727        assert_eq!(exports.len(), 1);
728        assert!(exports[0].test_referenced);
729    }
730
731    #[test]
732    fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
733        let files: Vec<_> = (0..2)
734            .map(|id| DiscoveredFile {
735                id: FileId(id),
736                path: PathBuf::from(format!("/project/file{id}.ts")),
737                size_bytes: 1,
738            })
739            .collect();
740        let modules = vec![
741            ResolvedModule {
742                file_id: FileId(0),
743                path: files[0].path.clone(),
744                resolved_imports: vec![
745                    import_with_mechanism(
746                        FileId(1),
747                        ImportedName::Named("esmOnly".to_string()),
748                        false,
749                    ),
750                    import_with_mechanism(
751                        FileId(1),
752                        ImportedName::Named("required".to_string()),
753                        true,
754                    ),
755                ],
756                ..ResolvedModule::default()
757            },
758            ResolvedModule {
759                file_id: FileId(1),
760                path: files[1].path.clone(),
761                exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
762                ..ResolvedModule::default()
763            },
764        ];
765        let test_entry_points = vec![EntryPoint {
766            path: files[0].path.clone(),
767            source: EntryPointSource::TestFile,
768        }];
769        let graph =
770            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
771                &modules,
772                &[ResolvedReplacedModuleTarget {
773                    source_file: FileId(0),
774                    target_file: FileId(1),
775                }],
776                &test_entry_points,
777                &[],
778                &test_entry_points,
779                &files,
780            ));
781
782        let exports = module_value_exports(&graph);
783        let coverage: rustc_hash::FxHashMap<_, _> = exports
784            .into_iter()
785            .map(|export| (export.name, export.test_referenced))
786            .collect();
787
788        assert_eq!(coverage.get("esmOnly"), Some(&false));
789        assert_eq!(coverage.get("required"), Some(&true));
790    }
791}