Skip to main content

brokk_bifrost_python/
usage_index.rs

1//! Analyzer-level re-export + importer index for Python, so both usage paths
2//! resolve references through analyzer state. Built once from the analyzer's own
3//! module index + `export_index_of` / `import_binder_of` and cached on
4//! [`PythonAnalyzer`] (dropped on `update`/`update_all` like the other caches).
5//!
6//! Forward export seeds follow re-export chains
7//! ([`PythonUsageIndex::seeds_for_target`]), and the reverse importer index
8//! resolves which local names in an importer bind a seed
9//! ([`PythonUsageIndex::matching_edges_for_importer`]). Candidate-file narrowing
10//! stays in the forward path's scoped import closure (`PythonProjectGraph`), not
11//! here. Module resolution reuses the analyzer's existing [`python_module_name`]
12//! + [`resolve_python_relative_module`].
13
14use brokk_bifrost_core::analyzer::query_token::QueryToken;
15use brokk_bifrost_core::analyzer::usages::local_inference::LocalBindingsSnapshot;
16use brokk_bifrost_core::analyzer::usages::model::{
17    ExportEntry, ExportIndex, ImportBinder, ImportBinding, ImportKind,
18};
19use brokk_bifrost_core::analyzer::usages::reexport_seeds;
20use brokk_bifrost_core::analyzer::usages::{ImportEdge, ImportEdgeKind};
21use brokk_bifrost_core::analyzer::{CodeUnit, Language, ProjectFile};
22use brokk_bifrost_core::hash::{HashMap, HashSet};
23use std::collections::BTreeSet;
24use std::sync::Arc;
25
26use crate::declarations::python_module_name;
27use crate::graph_support::{
28    PythonModuleSpellings, PythonSource, PythonUsageSource, export_index_from_file_facts,
29    import_binder_from_imports, import_bindings_from_imports, python_module_file_under_source_root,
30    retain_modules_for_importer,
31};
32use crate::imports::{
33    literal_importlib_modules, module_replacement_of, resolve_python_module_specifier,
34};
35
36/// Re-export and reverse-import indices over the Python workspace.
37#[derive(Debug, Default)]
38pub struct PythonUsageIndex {
39    module_index: HashMap<String, Vec<ProjectFile>>,
40    module_spellings: Arc<PythonModuleSpellings>,
41    exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>>,
42    reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>>,
43    star_reexports: HashMap<ProjectFile, Vec<ProjectFile>>,
44    importer_reverse: HashMap<ProjectFile, Vec<ImportEdge>>,
45    literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>>,
46}
47
48pub type ModuleBindingTimeline = HashMap<String, Vec<ModuleBindingEvent>>;
49pub type PythonScopeFacts = HashMap<CodeUnit, LocalBindingsSnapshot<String>>;
50
51#[derive(Clone, Debug)]
52pub struct ModuleBindingEvent {
53    pub visible_from: usize,
54    pub conditional: bool,
55    pub kind: ModuleBindingEventKind,
56}
57
58#[derive(Clone, Debug)]
59pub enum ModuleBindingEventKind {
60    ImportModule {
61        module: String,
62        consumed_attributes: usize,
63    },
64    FromImport {
65        module: String,
66        imported_name: String,
67    },
68    Other,
69}
70
71/// Keep events from the last unconditional binding through `cutoff`.
72pub fn visible_module_binding_suffix(
73    events: &[ModuleBindingEvent],
74    cutoff: usize,
75) -> impl Iterator<Item = &ModuleBindingEvent> {
76    let visible: Vec<_> = events
77        .iter()
78        .filter(|event| event.visible_from <= cutoff)
79        .collect();
80    let start = visible
81        .iter()
82        .rposition(|event| !event.conditional)
83        .unwrap_or(0);
84    visible.into_iter().skip(start)
85}
86
87/// Resolve a module specifier to the files defining it: a leading-dot specifier
88/// is made absolute against the importing file's package, then looked up in the
89/// module index.
90fn resolve_module(
91    module_index: &HashMap<String, Vec<ProjectFile>>,
92    module_spellings: &PythonModuleSpellings,
93    importing_file: &ProjectFile,
94    module_specifier: &str,
95) -> Vec<ProjectFile> {
96    let Some(project) = module_spellings.project() else {
97        return Vec::new();
98    };
99    let resolved_module =
100        resolve_python_module_specifier(project, importing_file, module_specifier);
101    let Some(resolved_module) = resolved_module else {
102        return Vec::new();
103    };
104    let mut files = module_index
105        .get(&resolved_module)
106        .cloned()
107        .unwrap_or_default();
108    // Two sibling source roots can index the same module name, so the root
109    // that owns the importing file decides which of them its import means.
110    retain_modules_for_importer(project, importing_file, &mut files, |file| file);
111    if files.is_empty() {
112        // No module name in the index spells the specifier, because it is
113        // written against a source root the workspace does not state (#3506).
114        files.extend(python_module_file_under_source_root(
115            module_spellings,
116            importing_file,
117            &resolved_module,
118        ));
119    }
120    files
121}
122
123fn is_sys_namespace_binding(binding: &ImportBinding) -> bool {
124    is_namespace_binding_for_module(binding, "sys")
125}
126
127fn is_importlib_namespace_binding(binding: &ImportBinding) -> bool {
128    is_namespace_binding_for_module(binding, "importlib")
129}
130
131fn is_namespace_binding_for_module(binding: &ImportBinding, module: &str) -> bool {
132    binding.kind == ImportKind::Namespace
133        && binding
134            .namespace_imported_module
135            .as_deref()
136            .unwrap_or(&binding.module_specifier)
137            == module
138}
139
140impl PythonUsageIndex {
141    /// The complete immutable authority. Lazy per-file syntax memos are not part of it.
142    pub fn authority_rows(&self) -> Vec<String> {
143        let mut rows = self.module_spellings.authority_rows();
144        for (name, files) in &self.module_index {
145            for file in files {
146                rows.push(format!(
147                    "module:{name:?}:{:?}",
148                    brokk_bifrost_core::path_utils::rel_path_string(file)
149                ));
150            }
151        }
152        for (file, exports) in &self.exports_by_file {
153            rows.push(format!(
154                "exports:{:?}:{:?}",
155                brokk_bifrost_core::path_utils::rel_path_string(file),
156                exports.exports_by_name.is_empty()
157            ));
158            for (name, export) in &exports.exports_by_name {
159                rows.push(format!(
160                    "export:{:?}:{name:?}:{export:?}",
161                    brokk_bifrost_core::path_utils::rel_path_string(file)
162                ));
163            }
164            for star in &exports.reexport_stars {
165                rows.push(format!(
166                    "star:{:?}:{star:?}",
167                    brokk_bifrost_core::path_utils::rel_path_string(file)
168                ));
169            }
170        }
171        for ((file, name), targets) in &self.reexport_edges {
172            for (target, export) in targets {
173                rows.push(format!(
174                    "reexport:{:?}:{name:?}:{:?}:{export:?}",
175                    brokk_bifrost_core::path_utils::rel_path_string(file),
176                    brokk_bifrost_core::path_utils::rel_path_string(target)
177                ));
178            }
179        }
180        for (file, targets) in &self.star_reexports {
181            for target in targets {
182                rows.push(format!(
183                    "star_target:{:?}:{:?}",
184                    brokk_bifrost_core::path_utils::rel_path_string(file),
185                    brokk_bifrost_core::path_utils::rel_path_string(target)
186                ));
187            }
188        }
189        for (file, edges) in &self.importer_reverse {
190            for edge in edges {
191                rows.push(format!(
192                    "importer:{:?}:{:?}:{:?}:{:?}:{:?}",
193                    brokk_bifrost_core::path_utils::rel_path_string(file),
194                    brokk_bifrost_core::path_utils::rel_path_string(&edge.importer),
195                    edge.local_name,
196                    brokk_bifrost_core::path_utils::rel_path_string(&edge.target_file),
197                    edge.kind
198                ));
199            }
200        }
201        for (file, importers) in &self.literal_importlib_importers {
202            for importer in importers {
203                rows.push(format!(
204                    "literal:{:?}:{:?}",
205                    brokk_bifrost_core::path_utils::rel_path_string(file),
206                    brokk_bifrost_core::path_utils::rel_path_string(importer)
207                ));
208            }
209        }
210        rows.sort();
211        rows
212    }
213
214    /// Takes [`PythonSource`], not [`PythonUsageSource`]: the cell this
215    /// build fills is only reachable through the latter, so the narrower
216    /// parameter is what stops the build from re-entering it.
217    pub fn build(
218        python: &dyn PythonSource,
219        token: QueryToken<'_>,
220        keep_going: &dyn Fn() -> bool,
221    ) -> Option<Self> {
222        if !keep_going() {
223            return None;
224        }
225        let _scope = brokk_bifrost_core::profiling::scope("PythonUsageIndex::build");
226        let mut files: Vec<ProjectFile> = python
227            .project()
228            .analyzable_files(Language::Python)
229            .map(|set| set.into_iter().collect())
230            .unwrap_or_default();
231        files.sort();
232        files.dedup();
233
234        let module_spellings = python.module_spellings();
235        let mut module_index: HashMap<String, Vec<ProjectFile>> = HashMap::default();
236        let mut exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>> = HashMap::default();
237        let mut binders_by_file: HashMap<ProjectFile, Arc<ImportBinder>> = HashMap::default();
238        let mut import_bindings_by_file: HashMap<ProjectFile, Vec<(String, ImportBinding)>> =
239            HashMap::default();
240        let mut literal_importlib_modules_by_file: HashMap<ProjectFile, HashSet<String>> =
241            HashMap::default();
242        let mut replacement_modules: HashMap<ProjectFile, String> = HashMap::default();
243        python.visit_file_facts(&files, keep_going, &mut |file, facts| {
244            if !keep_going() {
245                return;
246            }
247            let module_name = facts
248                .and_then(|facts| {
249                    facts
250                        .top_level_declarations()
251                        .iter()
252                        .find(|unit| unit.is_module())
253                })
254                .map(|unit| unit.fq_name().to_string())
255                .unwrap_or_else(|| python_module_name(python.project(), file));
256            module_index
257                .entry(module_name.clone())
258                .or_default()
259                .push(file.clone());
260            if let Some(facts) = facts {
261                let import_bindings = import_bindings_from_imports(python, file, facts.imports());
262                let binder = Arc::new(import_binder_from_imports(python, file, facts.imports()));
263                if binder.bindings.values().any(is_importlib_namespace_binding) {
264                    literal_importlib_modules_by_file.insert(
265                        file.clone(),
266                        literal_importlib_modules(facts.source(), &binder.bindings),
267                    );
268                }
269                if binder.bindings.values().any(is_sys_namespace_binding)
270                    && let Some(replacement) =
271                        module_replacement_of(python, file, facts.source(), facts.imports())
272                {
273                    replacement_modules.insert(file.clone(), replacement.target_module);
274                }
275                exports_by_file.insert(
276                    file.clone(),
277                    Arc::new(export_index_from_file_facts(
278                        python,
279                        file,
280                        facts,
281                        &module_name,
282                    )),
283                );
284                import_bindings_by_file.insert(file.clone(), import_bindings);
285                binders_by_file.insert(file.clone(), binder);
286            } else {
287                exports_by_file.insert(file.clone(), python.export_index_of(file));
288                let binder = python.import_binder_of(file);
289                let imports = python.import_info_of(token, file);
290                if let Ok(source) = python.project().read_source(file) {
291                    if binder.bindings.values().any(is_importlib_namespace_binding) {
292                        literal_importlib_modules_by_file.insert(
293                            file.clone(),
294                            literal_importlib_modules(&source, &binder.bindings),
295                        );
296                    }
297                    if binder.bindings.values().any(is_sys_namespace_binding)
298                        && let Some(replacement) =
299                            module_replacement_of(python, file, &source, &imports)
300                    {
301                        replacement_modules.insert(file.clone(), replacement.target_module);
302                    }
303                }
304                import_bindings_by_file.insert(
305                    file.clone(),
306                    import_bindings_from_imports(python, file, &imports),
307                );
308                binders_by_file.insert(file.clone(), binder);
309            }
310        });
311        for resolved in module_index.values_mut() {
312            if !keep_going() {
313                return None;
314            }
315            resolved.sort();
316            resolved.dedup();
317        }
318
319        let mut raw_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
320        for (file, target_module) in replacement_modules {
321            if !keep_going() {
322                return None;
323            }
324            let mut targets =
325                resolve_module(&module_index, &module_spellings, &file, &target_module);
326            if targets.len() != 1 {
327                continue;
328            }
329            let target = targets.pop().expect("one module replacement target");
330            if target != file {
331                raw_replacements.insert(file, target);
332            }
333        }
334
335        let mut canonical_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
336        for file in raw_replacements.keys() {
337            if !keep_going() {
338                return None;
339            }
340            if let Some(target) = canonical_module_replacement(file, &raw_replacements) {
341                canonical_replacements.insert(file.clone(), target);
342            }
343        }
344        for resolved in module_index.values_mut() {
345            if !keep_going() {
346                return None;
347            }
348            let mut seen = HashSet::default();
349            resolved.retain_mut(|file| {
350                if let Some(canonical) = canonical_replacements.get(file) {
351                    *file = canonical.clone();
352                }
353                seen.insert(file.clone())
354            });
355        }
356
357        let mut reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>> =
358            HashMap::default();
359        let mut star_reexports: HashMap<ProjectFile, Vec<ProjectFile>> = HashMap::default();
360        for (file, exports) in &exports_by_file {
361            if !keep_going() {
362                return None;
363            }
364            for (exported_name, entry) in &exports.exports_by_name {
365                if !keep_going() {
366                    return None;
367                }
368                match entry {
369                    ExportEntry::Local { local_name } => {
370                        let Some(binder) = binders_by_file.get(file) else {
371                            continue;
372                        };
373                        let Some(binding) = binder.bindings.get(local_name) else {
374                            continue;
375                        };
376                        let Some(imported_name) = binding.imported_name.as_ref() else {
377                            continue;
378                        };
379                        for resolved_file in resolve_module(
380                            &module_index,
381                            &module_spellings,
382                            file,
383                            &binding.module_specifier,
384                        ) {
385                            reexport_edges
386                                .entry((resolved_file, imported_name.clone()))
387                                .or_default()
388                                .push((file.clone(), exported_name.clone()));
389                        }
390                    }
391                    // A re-exported module is a terminal binding: it carries
392                    // no member name, so it opens no name-to-name edge.
393                    ExportEntry::Default { .. } | ExportEntry::ReexportedModule { .. } => {}
394                    ExportEntry::ReexportedNamed {
395                        module_specifier,
396                        imported_name,
397                    } => {
398                        for resolved_file in
399                            resolve_module(&module_index, &module_spellings, file, module_specifier)
400                        {
401                            reexport_edges
402                                .entry((resolved_file, imported_name.clone()))
403                                .or_default()
404                                .push((file.clone(), exported_name.clone()));
405                        }
406                    }
407                }
408            }
409            for star in &exports.reexport_stars {
410                for resolved_file in resolve_module(
411                    &module_index,
412                    &module_spellings,
413                    file,
414                    &star.module_specifier,
415                ) {
416                    star_reexports
417                        .entry(resolved_file)
418                        .or_default()
419                        .push(file.clone());
420                }
421            }
422        }
423
424        if !keep_going() {
425            return None;
426        }
427        let importer_reverse = build_importer_reverse(
428            &module_index,
429            &module_spellings,
430            &files,
431            &import_bindings_by_file,
432            &exports_by_file,
433            keep_going,
434        )?;
435        let mut literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>> =
436            HashMap::default();
437        for (importer, modules) in literal_importlib_modules_by_file {
438            if !keep_going() {
439                return None;
440            }
441            for module in modules {
442                for target_file in
443                    resolve_module(&module_index, &module_spellings, &importer, &module)
444                {
445                    literal_importlib_importers
446                        .entry(target_file.clone())
447                        .or_default()
448                        .push(importer.clone());
449                }
450            }
451        }
452        for importers in literal_importlib_importers.values_mut() {
453            if !keep_going() {
454                return None;
455            }
456            importers.sort();
457            importers.dedup();
458        }
459
460        if !keep_going() {
461            return None;
462        }
463        Some(Self {
464            module_index,
465            module_spellings,
466            exports_by_file,
467            reexport_edges,
468            star_reexports,
469            importer_reverse,
470            literal_importlib_importers,
471        })
472    }
473
474    pub fn seeds_for_target(
475        &self,
476        target_file: &ProjectFile,
477        target_short: &str,
478    ) -> BTreeSet<(ProjectFile, String)> {
479        let mut seeds: BTreeSet<(ProjectFile, String)> = BTreeSet::new();
480
481        if let Some(exports) = self.exports_by_file.get(target_file) {
482            for (exported_name, entry) in &exports.exports_by_name {
483                let local = match entry {
484                    ExportEntry::Local { local_name } => Some(local_name.as_str()),
485                    ExportEntry::Default { local_name } => local_name.as_deref(),
486                    ExportEntry::ReexportedNamed { .. } | ExportEntry::ReexportedModule { .. } => {
487                        None
488                    }
489                };
490                if let Some(local_name) = local
491                    && local_name == target_short
492                {
493                    seeds.insert((target_file.clone(), exported_name.clone()));
494                }
495            }
496        }
497
498        reexport_seeds::expand_reexport_seeds(
499            seeds,
500            &self.reexport_edges,
501            &self.star_reexports,
502            |name| !name.starts_with('_'),
503        )
504    }
505
506    pub fn matching_edges_for_importer(
507        &self,
508        importer: &ProjectFile,
509        seeds: &BTreeSet<(ProjectFile, String)>,
510    ) -> Vec<ImportEdge> {
511        reexport_seeds::matching_edges_for_importer(&self.importer_reverse, importer, seeds)
512    }
513
514    pub fn importer_files_for_seeds(
515        &self,
516        seeds: &BTreeSet<(ProjectFile, String)>,
517    ) -> HashSet<ProjectFile> {
518        let mut importers = HashSet::default();
519        for (target_file, _) in seeds {
520            if let Some(edges) = self.importer_reverse.get(target_file) {
521                importers.extend(
522                    edges
523                        .iter()
524                        .filter(|edge| reexport_seeds::edge_matches_seed(edge, seeds))
525                        .map(|edge| edge.importer.clone()),
526                );
527            }
528            if let Some(dynamic_importers) = self.literal_importlib_importers.get(target_file) {
529                importers.extend(dynamic_importers.iter().cloned());
530            }
531        }
532        importers
533    }
534
535    pub fn resolve_module_files(
536        &self,
537        importing_file: &ProjectFile,
538        module_specifier: &str,
539    ) -> Vec<ProjectFile> {
540        resolve_module(
541            &self.module_index,
542            &self.module_spellings,
543            importing_file,
544            module_specifier,
545        )
546    }
547
548    pub(crate) fn relative_module_leaves_workspace(
549        &self,
550        importing_file: &ProjectFile,
551        module_specifier: &str,
552    ) -> bool {
553        self.module_spellings.project().is_some_and(|project| {
554            crate::imports::resolve_python_relative_module(
555                project,
556                importing_file,
557                module_specifier,
558            )
559            .is_none()
560        })
561    }
562}
563
564fn canonical_module_replacement(
565    file: &ProjectFile,
566    replacements: &HashMap<ProjectFile, ProjectFile>,
567) -> Option<ProjectFile> {
568    let mut seen = BTreeSet::new();
569    let mut current = file.clone();
570    while let Some(target) = replacements.get(&current) {
571        if !seen.insert(current) {
572            return None;
573        }
574        current = target.clone();
575    }
576    Some(current)
577}
578
579fn build_importer_reverse(
580    module_index: &HashMap<String, Vec<ProjectFile>>,
581    module_spellings: &PythonModuleSpellings,
582    files: &[ProjectFile],
583    bindings_by_file: &HashMap<ProjectFile, Vec<(String, ImportBinding)>>,
584    exports_by_file: &HashMap<ProjectFile, Arc<ExportIndex>>,
585    keep_going: &dyn Fn() -> bool,
586) -> Option<HashMap<ProjectFile, Vec<ImportEdge>>> {
587    let mut reverse: HashMap<ProjectFile, Vec<ImportEdge>> = HashMap::default();
588    for file in files {
589        if !keep_going() {
590            return None;
591        }
592        let Some(bindings) = bindings_by_file.get(file) else {
593            continue;
594        };
595        for (local_name, binding) in bindings {
596            let imported_module = binding
597                .namespace_imported_module
598                .as_deref()
599                .unwrap_or(&binding.module_specifier);
600            for target_file in resolve_module(module_index, module_spellings, file, imported_module)
601            {
602                // A glob `from m import *` binds every export of the target file
603                // as a named edge, mirroring the graph it replaces.
604                if matches!(binding.kind, ImportKind::Glob) {
605                    let Some(exports) = exports_by_file.get(&target_file) else {
606                        continue;
607                    };
608                    for export_name in exports.exports_by_name.keys() {
609                        if export_name.starts_with('_') {
610                            continue;
611                        }
612                        reverse
613                            .entry(target_file.clone())
614                            .or_default()
615                            .push(ImportEdge {
616                                importer: file.clone(),
617                                local_name: export_name.clone(),
618                                target_file: target_file.clone(),
619                                kind: ImportEdgeKind::Named(export_name.clone()),
620                            });
621                    }
622                    continue;
623                }
624                let kind = match (binding.kind, binding.imported_name.as_deref()) {
625                    (ImportKind::Default, _) => ImportEdgeKind::Default,
626                    (ImportKind::Namespace, _) => ImportEdgeKind::Namespace,
627                    (ImportKind::Named, Some(name)) => ImportEdgeKind::Named(name.to_string()),
628                    (ImportKind::Named, None) => ImportEdgeKind::Named(local_name.clone()),
629                    // Python binders never emit CommonJsRequire; glob handled above.
630                    (ImportKind::CommonJsRequire, _) | (ImportKind::Glob, _) => continue,
631                };
632                reverse
633                    .entry(target_file.clone())
634                    .or_default()
635                    .push(ImportEdge {
636                        importer: file.clone(),
637                        local_name: local_name.clone(),
638                        target_file,
639                        kind,
640                    });
641            }
642        }
643    }
644    keep_going().then_some(reverse)
645}
646
647/// Export seeds for the target, following re-export chains.
648pub fn usage_seeds(
649    python: &dyn PythonUsageSource,
650    target_file: &ProjectFile,
651    target_short: &str,
652) -> BTreeSet<(ProjectFile, String)> {
653    python
654        .usage_index()
655        .seeds_for_target(target_file, target_short)
656}
657
658/// The import edges in `importer` that bind one of the `seeds`.
659pub fn usage_matching_edges(
660    python: &dyn PythonUsageSource,
661    importer: &ProjectFile,
662    seeds: &BTreeSet<(ProjectFile, String)>,
663) -> Vec<ImportEdge> {
664    python
665        .usage_index()
666        .matching_edges_for_importer(importer, seeds)
667}
668
669pub fn usage_importer_files(
670    python: &dyn PythonUsageSource,
671    seeds: &BTreeSet<(ProjectFile, String)>,
672) -> HashSet<ProjectFile> {
673    python.usage_index().importer_files_for_seeds(seeds)
674}
675
676pub fn usage_resolve_module_files(
677    python: &dyn PythonUsageSource,
678    importing_file: &ProjectFile,
679    module_specifier: &str,
680) -> Vec<ProjectFile> {
681    python
682        .usage_index()
683        .resolve_module_files(importing_file, module_specifier)
684}
685
686pub fn usage_module_binding_timeline(
687    python: &dyn PythonUsageSource,
688    file: &ProjectFile,
689    build: impl FnOnce() -> ModuleBindingTimeline,
690) -> Arc<ModuleBindingTimeline> {
691    let mut build = Some(build);
692    python.module_binding_timeline(file, &mut || {
693        build.take().expect("memo builds at most once")()
694    })
695}
696
697pub fn usage_scope_facts(
698    python: &dyn PythonUsageSource,
699    file: &ProjectFile,
700    build: impl FnOnce() -> Option<PythonScopeFacts>,
701) -> Option<Arc<PythonScopeFacts>> {
702    let mut build = Some(build);
703    python.scope_facts(file, &mut || {
704        build.take().expect("memo builds at most once")()
705    })
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use brokk_bifrost_test_macros::hardwon;
712
713    #[test]
714    #[hardwon("59dfb80c3bb8: module replacement chains failed to canonicalize or reject cycles")]
715    fn module_replacement_chains_canonicalize_and_cycles_are_rejected() {
716        let root = tempfile::tempdir().expect("temporary project root");
717        let first = ProjectFile::new(root.path(), "first.py");
718        let second = ProjectFile::new(root.path(), "second.py");
719        let canonical = ProjectFile::new(root.path(), "canonical.py");
720        let chain = HashMap::from_iter([
721            (first.clone(), second.clone()),
722            (second.clone(), canonical.clone()),
723        ]);
724
725        assert_eq!(
726            canonical_module_replacement(&first, &chain),
727            Some(canonical)
728        );
729
730        let cycle = HashMap::from_iter([(first.clone(), second.clone()), (second, first.clone())]);
731        assert_eq!(canonical_module_replacement(&first, &cycle), None);
732    }
733}