char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop: factor module prep/transpilatione/etc into loader.rsab5df08

main
30.0 KiB732 linesraw
1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::path::Path;
3use std::rc::Rc;
4
5use avril_core::{Error, ModuleFormat, ModuleLoader, ModuleSource};
6use futures::future::LocalBoxFuture;
7use oxc::allocator::Allocator;
8use oxc::codegen::{Codegen, CodegenOptions};
9use oxc::diagnostics::{Diagnostics, NamedSource};
10use oxc::parser::Parser;
11use oxc::semantic::SemanticBuilder;
12use oxc::span::SourceType;
13use oxc::transformer::{EnvOptions, Module, TransformOptions, Transformer};
14use oxc_sourcemap::Token;
15
16use crate::data_modules::DataModule;
17use crate::local_packages::ResolveMode;
18use crate::modules::{ModuleGraph, ResolutionMode, ResolutionTarget, data_url, http_url};
19use crate::registry::npm::package::NpmModuleKind;
20
21#[derive(Default)]
22pub(crate) struct LoaderState {
23    pub(crate) entry: Option<String>,
24    pub(crate) records: HashMap<String, ModuleRecord>,
25}
26
27#[derive(Clone, Copy, PartialEq, Eq)]
28enum ModuleState {
29    Ready,
30    Complete,
31}
32
33pub(crate) struct ModuleRecord {
34    state: ModuleState,
35    source: Option<ModuleSource>,
36    specifiers: Option<Vec<String>>,
37    imports: HashMap<String, String>,
38    source_map: Option<Vec<Token>>,
39}
40
41/// Warm-only notice for a skipped edge: the unsupported specifier plus the
42/// importing module. Strict walks never invoke it.
43pub type BuiltinWarning = Rc<dyn Fn(&str, &str)>;
44
45impl ModuleGraph {
46    pub fn set_entry(&self, name: &str) {
47        self.loader.borrow_mut().entry = Some(name.to_owned());
48    }
49
50    pub fn entry_url(&self) -> String {
51        self.loader
52            .borrow()
53            .entry
54            .as_deref()
55            .and_then(|name| self.module_url(name))
56            .unwrap_or_default()
57    }
58
59    pub fn info(&self, root: &str) -> Result<serde_json::Value, Error> {
60        let complete: Vec<(String, HashMap<String, String>)> = {
61            let loader = self.loader.borrow();
62            let records = &loader.records;
63            records
64                .iter()
65                .filter(|(_, record)| record.state == ModuleState::Complete)
66                .map(|(name, record)| (name.clone(), record.imports.clone()))
67                .collect()
68        };
69        let modules: BTreeMap<_, _> = complete
70            .into_iter()
71            .map(|(name, imports)| {
72                self.check_access(&name)?;
73                let imports: BTreeMap<_, _> = imports.into_iter().collect();
74                let metadata = self.package_metadata(&name)?;
75                Ok((name.clone(), serde_json::json!({"url": self.module_url(&name), "imports": imports, "package": metadata})))
76            })
77            .collect::<Result<_, Error>>()?;
78        Ok(serde_json::json!({"root": root, "modules": modules}))
79    }
80
81    /// Local execution format by extension. `.js` stays ESM per avril
82    /// policy (no `package.json` `type` behavior); explicit `.cjs`/`.cts`
83    /// are CommonJS and `.json` is JSON for `require`.
84    fn local_format(name: &str) -> ModuleFormat {
85        let lower = name.to_ascii_lowercase();
86        if lower.ends_with(".cjs") || lower.ends_with(".cts") {
87            ModuleFormat::CommonJs
88        } else if lower.ends_with(".json") {
89            ModuleFormat::Json
90        } else {
91            ModuleFormat::Esm
92        }
93    }
94
95    fn module_format_sync(&self, name: &str) -> Result<ModuleFormat, Error> {
96        if name.starts_with("avril:") || http_url(name).is_some() || data_url(name).is_some() {
97            // Remote CJS stays explicitly unsupported; remote files execute
98            // as ESM so `require` of them fails with the ESM message.
99            return Ok(ModuleFormat::Esm);
100        }
101        if self.npm.is_reserved_npm_path(Path::new(name)) {
102            return match self.npm.check_access(Path::new(name)) {
103                Ok(NpmModuleKind::Esm) | Ok(NpmModuleKind::TypeScript) => Ok(ModuleFormat::Esm),
104                Ok(NpmModuleKind::CommonJs) => Ok(ModuleFormat::CommonJs),
105                Ok(NpmModuleKind::Json) => Ok(ModuleFormat::Json),
106                Ok(NpmModuleKind::Declaration) => Err(Error::Module(format!(
107                    "{name}: npm declaration files have no runtime code"
108                ))),
109                Err(error) => Err(Error::Module(error.to_string())),
110            };
111        }
112        Ok(Self::local_format(name))
113    }
114
115    pub fn prepare_for_add(
116        &self,
117        specifier: String,
118        referrer: String,
119        warn_unsupported: BuiltinWarning,
120    ) -> LocalBoxFuture<'static, Result<String, Error>> {
121        self.prepare_with(specifier, referrer, Some(warn_unsupported))
122    }
123
124    fn prepare_with(
125        &self,
126        specifier: String,
127        referrer: String,
128        warn_unsupported: Option<BuiltinWarning>,
129    ) -> LocalBoxFuture<'static, Result<String, Error>> {
130        let graph = self.clone();
131        Box::pin(async move {
132            let (root, request) = if referrer.is_empty() {
133                (data_url(&specifier).map_or(specifier, String::from), None)
134            } else {
135                graph.resolve_tracked(&specifier, &referrer).await?
136            };
137            let mut requests: BTreeSet<_> = request.into_iter().collect();
138            let mut pending = vec![root.clone()];
139            let mut seen = HashSet::new();
140            let mut links = HashMap::new();
141            while let Some(name) = pending.pop() {
142                if name.starts_with("avril:") || !seen.insert(name.clone()) {
143                    continue;
144                }
145                graph.check_access(&name)?;
146                graph.initialize_package(&name).await?;
147                let is_complete = {
148                    graph
149                        .loader
150                        .borrow()
151                        .records
152                        .get(&name)
153                        .is_some_and(|record| record.state == ModuleState::Complete)
154                };
155                if is_complete {
156                    continue;
157                }
158                let cached = {
159                    graph.loader.borrow().records.get(&name).and_then(|record| {
160                        Some((record.source.clone()?, record.specifiers.clone()?))
161                    })
162                };
163                let format = graph.module_format_sync(&name)?;
164                if format != ModuleFormat::Esm {
165                    if cached.is_none() {
166                        let raw = graph.original_source(&name).await?;
167                        let (code, maps) = if format == ModuleFormat::Json {
168                            (raw, None)
169                        } else if name.to_ascii_lowercase().ends_with(".cts") {
170                            let source_type = graph.source_type(&name)?;
171                            let (code, maps) =
172                                transpile(&name, &raw, source_type, TransformOptions::default())?;
173                            (code, if maps.is_empty() { None } else { Some(maps) })
174                        } else {
175                            (raw, None)
176                        };
177                        let source: ModuleSource = code.into();
178                        {
179                            let mut loader = graph.loader.borrow_mut();
180                            let records = &mut loader.records;
181                            match records.get_mut(&name) {
182                                Some(record) => {
183                                    record.source = Some(source);
184                                    record.specifiers = Some(Vec::new());
185                                    record.source_map = maps;
186                                }
187                                None => {
188                                    records.insert(
189                                        name.clone(),
190                                        ModuleRecord {
191                                            state: ModuleState::Ready,
192                                            source: Some(source),
193                                            specifiers: Some(Vec::new()),
194                                            imports: HashMap::new(),
195                                            source_map: maps,
196                                        },
197                                    );
198                                }
199                            }
200                        }
201                    }
202                    requests.extend(graph.prepare_npm_roots(&name, format, &[]).await?);
203                    links.insert(name, HashMap::new());
204                    continue;
205                }
206                let specifiers = match cached {
207                    Some((_, specifiers)) => specifiers,
208                    None => {
209                        let raw = if name.starts_with(graph.store.options().jsr_registry.as_str()) {
210                            let hints = graph.store.hints(&name).await.unwrap_or_default();
211                            let prefetch = futures::future::join_all(
212                                hints
213                                    .iter()
214                                    .filter(|hint| graph.check_access(hint).is_ok())
215                                    .map(|hint| graph.store.source(hint)),
216                            );
217                            let (source, _) =
218                                futures::future::join(graph.original_source(&name), prefetch).await;
219                            source?
220                        } else {
221                            graph.original_source(&name).await?
222                        };
223                        let (code, mappings) = match graph.source_type(&name)? {
224                            source_type if source_type.is_typescript() || source_type.is_jsx() => {
225                                let options = if source_type.is_jsx() {
226                                    graph.jsx_config(&name)?.transform_options().map_err(
227                                        |error| Error::Module(format!("{name}: {error}")),
228                                    )?
229                                } else {
230                                    TransformOptions::default()
231                                };
232                                let (code, mappings) =
233                                    transpile(&name, &raw, source_type, options)?;
234                                (code, Some(mappings))
235                            }
236                            _ => (raw, None),
237                        };
238                        let specifiers = match import_specifiers(&name, &code) {
239                            Ok(specifiers) => specifiers,
240                            Err(error) => {
241                                // Another prepare may have stored or completed the record
242                                // while this one awaited; never clear its source, state,
243                                // or imports, only fill a missing source map.
244                                if let Some(mappings) = mappings {
245                                    let mut loader = graph.loader.borrow_mut();
246                                    let records = &mut loader.records;
247                                    match records.get_mut(&name) {
248                                        Some(record)
249                                            if record.state != ModuleState::Complete
250                                                && record.source.is_none() =>
251                                        {
252                                            record.source_map = Some(mappings);
253                                        }
254                                        None => {
255                                            records.insert(
256                                                name.clone(),
257                                                ModuleRecord {
258                                                    state: ModuleState::Ready,
259                                                    source: None,
260                                                    specifiers: None,
261                                                    imports: HashMap::new(),
262                                                    source_map: Some(mappings),
263                                                },
264                                            );
265                                        }
266                                        _ => {}
267                                    }
268                                }
269                                return Err(error);
270                            }
271                        };
272                        let source: ModuleSource = code.into();
273                        {
274                            let mut loader = graph.loader.borrow_mut();
275                            let records = &mut loader.records;
276                            match records.get_mut(&name) {
277                                Some(record) => {
278                                    // Refresh only the fetch artifacts; a concurrent
279                                    // prepare's state and imports must survive.
280                                    record.source = Some(source);
281                                    record.specifiers = Some(specifiers.clone());
282                                    record.source_map = mappings;
283                                }
284                                None => {
285                                    records.insert(
286                                        name.clone(),
287                                        ModuleRecord {
288                                            state: ModuleState::Ready,
289                                            source: Some(source),
290                                            specifiers: Some(specifiers.clone()),
291                                            imports: HashMap::new(),
292                                            source_map: mappings,
293                                        },
294                                    );
295                                }
296                            }
297                        }
298                        specifiers
299                    }
300                };
301                graph.prepare_npm_roots(&name, format, &specifiers).await?;
302                let mut dependencies = Vec::new();
303                let mut imports = HashMap::new();
304                for specifier in &specifiers {
305                    if let Some(warn) = &warn_unsupported
306                        && specifier.starts_with("node:")
307                    {
308                        warn(specifier, &name);
309                        continue;
310                    }
311                    let (dependency, request) = graph.resolve_tracked(specifier, &name).await?;
312                    requests.extend(request);
313                    imports.insert(specifier.clone(), dependency.clone());
314                    dependencies.push(dependency);
315                }
316                pending.extend(dependencies.into_iter().rev());
317                links.insert(name, imports);
318            }
319            graph
320                .store
321                .save_graph(&requests, &seen)
322                .map_err(|error| Error::Module(error.to_string()))?;
323            {
324                let mut loader = graph.loader.borrow_mut();
325                let records = &mut loader.records;
326                // Every name needing completion is in links; names in seen but not
327                // in links were already Complete and skipped above.
328                for (name, imports) in links {
329                    if let Some(record) = records.get_mut(&name)
330                        && record.state != ModuleState::Complete
331                    {
332                        record.imports = imports;
333                        // Warm-only walks cache sources without completing
334                        // records, so a later strict walk still validates
335                        // every edge including skipped builtins.
336                        if warn_unsupported.is_none() {
337                            record.state = ModuleState::Complete;
338                        }
339                    }
340                }
341            }
342            Ok(root)
343        })
344    }
345}
346
347impl ModuleLoader for ModuleGraph {
348    fn resolve(&self, specifier: &str, referrer: &str) -> Result<String, Error> {
349        if specifier.starts_with("avril:") {
350            return Ok(specifier.to_owned());
351        }
352        self.loader
353            .borrow()
354            .records
355            .get(referrer)
356            .and_then(|record| record.imports.get(specifier))
357            .cloned()
358            .ok_or_else(|| {
359                Error::Module(format!(
360                    "Import was not prepared: {specifier} from {referrer}"
361                ))
362            })
363    }
364
365    fn check_access(&self, name: &str) -> Result<(), Error> {
366        if data_url(name).is_some() {
367            DataModule::parse(name)?;
368            return Ok(());
369        }
370        if name.starts_with("avril:") {
371            return Err(Error::Module(format!(
372                "Unsupported module specifier: {name} (unknown builtin)"
373            )));
374        }
375        if let Some(url) = http_url(name) {
376            if !url.username().is_empty() || url.password().is_some() {
377                return Err(Error::Module(format!(
378                    "HTTP module credentials are not supported: {name}"
379                )));
380            }
381            if name.starts_with(self.store.options().jsr_registry.as_str()) {
382                self.store
383                    .module_base(name)
384                    .map_err(|error| Error::Module(error.to_string()))?;
385            }
386            return self
387                .store
388                .check_acquired_remote(name, &self.authority)
389                .map_err(|error| Error::Module(error.to_string()));
390        }
391        // Reserved npm paths always authorize through virtual-URL
392        // authority: project filesystem grants never cover them, and
393        // unowned cache paths fail instead of loading as local files.
394        // Declarations have no executable code even when readable.
395        if self.npm.is_reserved_npm_path(Path::new(name)) {
396            return match self.npm.check_access(Path::new(name)) {
397                Ok(NpmModuleKind::Declaration) => Err(Error::Module(format!(
398                    "{name}: npm declaration files have no runtime code"
399                ))),
400                Ok(_) => Ok(()),
401                Err(error) => Err(Error::Module(error.to_string())),
402            };
403        }
404        self.target(name).map(|_| ())
405    }
406
407    fn load(&self, name: &str) -> Result<ModuleSource, Error> {
408        self.check_access(name)?;
409        if let Some(source) = self
410            .loader
411            .borrow()
412            .records
413            .get(name)
414            .and_then(|record| record.source.clone())
415        {
416            return Ok(source);
417        }
418        let format = self.module_format_sync(name)?;
419        match format {
420            ModuleFormat::Esm => Err(Error::Module(format!("Module was not prepared: {name}"))),
421            ModuleFormat::CommonJs | ModuleFormat::Json => {
422                let (code, maps) = if matches!(format, ModuleFormat::CommonJs)
423                    && name.to_ascii_lowercase().ends_with(".cts")
424                {
425                    let raw = self.sync_source(name)?;
426                    let source_type = self.source_type(name)?;
427                    let (code, maps) =
428                        transpile(name, &raw, source_type, TransformOptions::default())?;
429                    (code, if maps.is_empty() { None } else { Some(maps) })
430                } else {
431                    (self.sync_source(name)?, None)
432                };
433                let source: ModuleSource = code.into();
434                let mut loader = self.loader.borrow_mut();
435                let records = &mut loader.records;
436                match records.get_mut(name) {
437                    Some(record) => {
438                        record.source = Some(source.clone());
439                        record.specifiers = Some(Vec::new());
440                        record.source_map = maps;
441                    }
442                    None => {
443                        records.insert(
444                            name.to_owned(),
445                            ModuleRecord {
446                                state: ModuleState::Complete,
447                                source: Some(source.clone()),
448                                specifiers: Some(Vec::new()),
449                                imports: HashMap::new(),
450                                source_map: maps,
451                            },
452                        );
453                    }
454                }
455                Ok(source)
456            }
457        }
458    }
459
460    fn module_format(&self, name: &str) -> Result<ModuleFormat, Error> {
461        self.module_format_sync(name)
462    }
463
464    fn require_resolve(&self, specifier: &str, referrer: &str) -> Result<String, Error> {
465        self.require_resolve_sync(specifier, referrer)
466    }
467
468    fn cjs_named_exports(&self, name: &str) -> Result<Vec<String>, Error> {
469        self.check_access(name)?;
470        let source = self.load(name)?;
471        self.check_access(name)?;
472        Ok(crate::commonjs::named_exports(&source))
473    }
474
475    fn prepare(
476        &self,
477        specifier: String,
478        referrer: String,
479    ) -> LocalBoxFuture<'static, Result<String, Error>> {
480        self.prepare_with(specifier, referrer, None)
481    }
482
483    /// Mirrors `resolve_tracked`'s decision order, but never acquires: unprepared
484    /// jsr/npm packages error instead of fetching, missing targets are allowed,
485    /// local names present as `file:` URLs, and no lock key is produced.
486    fn resolve_import_meta(&self, specifier: &str, referrer: &str) -> Result<String, Error> {
487        let referrer = self.classify_referrer(referrer)?;
488        if let Some(name) = Self::early_scheme(specifier)? {
489            return Ok(name);
490        }
491        if let Some(path) = referrer.path.as_deref()
492            && referrer.npm
493        {
494            if let Some(url) = data_url(specifier) {
495                return Ok(url.to_string());
496            }
497            return self
498                .npm
499                .resolve_from_file(specifier, path, ResolutionMode::Import)
500                .map_err(|error| error.to_string())
501                .and_then(|resolved| {
502                    let path = resolved
503                        .path
504                        .to_str()
505                        .ok_or_else(|| "Invalid module path".to_owned())?;
506                    url::Url::from_file_path(path)
507                        .map(String::from)
508                        .map_err(|_| "Invalid module path".to_owned())
509                })
510                .map_err(Error::Module);
511        }
512        let mapped = if referrer.inline {
513            None
514        } else if referrer.remote {
515            self.remote.resolve_import_meta(specifier, &referrer.name)?
516        } else {
517            self.packages
518                .resolve_import_meta(specifier, &referrer.name, &self.remote)?
519        };
520        let effective = match &mapped {
521            Some(crate::local_packages::Resolution::Specifier(target)) => target.as_str(),
522            _ => specifier,
523        };
524        if effective.starts_with("npm:") {
525            return self.resolve_import_meta_npm(effective, &referrer.name, referrer.remote);
526        }
527        match self.resolve_target(specifier, &referrer, mapped, ResolveMode::PreparedOnly)? {
528            ResolutionTarget::Jsr(specifier) => crate::registry::parse_jsr_request(&specifier)
529                .and_then(|request| self.store.resolve_import_meta_request(&request))
530                .map_err(|error| Error::Module(error.to_string())),
531            ResolutionTarget::Npm(specifier) => {
532                self.resolve_import_meta_npm(&specifier, &referrer.name, referrer.remote)
533            }
534            ResolutionTarget::Remote(name) => Ok(name),
535            ResolutionTarget::Module(name)
536                if name.starts_with("avril:")
537                    || http_url(&name).is_some()
538                    || data_url(&name).is_some() =>
539            {
540                Ok(name)
541            }
542            ResolutionTarget::Module(name) => self
543                .module_url(&name)
544                .ok_or_else(|| Error::Module("Invalid module path".into())),
545        }
546    }
547
548    fn module_url(&self, name: &str) -> Option<String> {
549        if http_url(name).is_some() || data_url(name).is_some() {
550            return Some(name.to_owned());
551        }
552        url::Url::from_file_path(name).ok().map(String::from)
553    }
554
555    fn map_source_location(
556        &self,
557        name: &str,
558        line: u32,
559        column: u32,
560    ) -> Option<(String, u32, u32)> {
561        let loader = self.loader.borrow();
562        let records = &loader.records;
563        let mappings = records.get(name)?.source_map.as_ref()?;
564        let position = (line.checked_sub(1)?, column.checked_sub(1)?);
565        let index = mappings
566            .partition_point(|token| (token.get_dst_line(), token.get_dst_col()) <= position)
567            .checked_sub(1)?;
568        let token = mappings[index];
569        token.get_source_id()?;
570        (token.get_dst_line() == position.0).then(|| {
571            (
572                name.to_owned(),
573                token.get_src_line() + 1,
574                token.get_src_col() + 1,
575            )
576        })
577    }
578}
579
580fn import_specifiers(name: &str, source: &str) -> Result<Vec<String>, Error> {
581    let allocator = Allocator::default();
582    let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse();
583    if !parsed.diagnostics.is_empty() {
584        let diagnostics: Vec<_> = parsed
585            .diagnostics
586            .into_iter()
587            .map(|diagnostic| diagnostic.render_with_source_code(NamedSource::new(name, source)))
588            .collect();
589        return Err(Error::Syntax(format!("{name}\n{}", diagnostics.join(""))));
590    }
591    let mut imports: Vec<_> = parsed.module_record.requested_modules.iter().collect();
592    imports.sort_by_key(|(_, requests)| requests.iter().map(|request| request.span.start).min());
593    Ok(imports
594        .into_iter()
595        .map(|(specifier, _)| specifier.to_string())
596        .collect())
597}
598
599pub(crate) fn transpile(
600    name: &str,
601    source: &str,
602    source_type: SourceType,
603    options: TransformOptions,
604) -> Result<(String, Vec<Token>), Error> {
605    let (code, map) = compile(name, Path::new(name), source, source_type, options)?;
606    Ok((code, map.get_tokens().collect()))
607}
608
609/// Strips types and compiles JSX, keeping CommonJS source types' module
610/// syntax. `path` is the file name the transformer sees, as in JSX
611/// development metadata; maps always name `name`.
612pub(crate) fn compile(
613    name: &str,
614    path: &Path,
615    source: &str,
616    source_type: SourceType,
617    mut options: TransformOptions,
618) -> Result<(String, oxc_sourcemap::SourceMap<'static>), Error> {
619    if source_type.is_typescript_definition() {
620        return Err(Error::Module(format!(
621            "{name}: TypeScript declaration files have no runtime code"
622        )));
623    }
624    let commonjs = source_type.is_commonjs();
625    let report = |diagnostics: Diagnostics| {
626        let rendered: Vec<_> = diagnostics
627            .into_iter()
628            .map(|diagnostic| diagnostic.render_with_source_code(NamedSource::new(name, source)))
629            .collect();
630        format!(
631            "Failed to transpile {name}\n{}",
632            rendered.join("").trim_end()
633        )
634    };
635
636    let allocator = Allocator::default();
637    let parsed = Parser::new(
638        &allocator,
639        source,
640        if commonjs {
641            source_type
642        } else {
643            source_type.with_module(true)
644        },
645    )
646    .parse();
647    if !parsed.diagnostics.is_empty() {
648        return Err(Error::Syntax(report(parsed.diagnostics)));
649    }
650    let mut program = parsed.program;
651    let semantic = SemanticBuilder::new_compiler()
652        .with_enum_eval(true)
653        .build(&program);
654    if !semantic.diagnostics.is_empty() {
655        return Err(Error::Syntax(report(semantic.diagnostics)));
656    }
657    options.env = EnvOptions {
658        module: if commonjs {
659            Module::Preserve
660        } else {
661            Module::Esm
662        },
663        ..EnvOptions::default()
664    };
665    let transformed = Transformer::new(&allocator, path, &options)
666        .build_with_scoping(semantic.semantic.into_scoping(), &mut program);
667    // Oxc warns where its output would not match TypeScript, such as for CommonJS-only syntax.
668    if !transformed.diagnostics.is_empty() {
669        return Err(Error::Module(report(transformed.diagnostics)));
670    }
671
672    let generated = Codegen::new()
673        .with_options(CodegenOptions {
674            source_map_path: Some(name.into()),
675            ..CodegenOptions::default()
676        })
677        .build(&program);
678    Ok((
679        generated.code,
680        generated.map.expect("maps are requested").into_owned(),
681    ))
682}
683
684#[cfg(test)]
685mod warm_builtin_tests {
686    use avril_core::ModuleLoader;
687    use std::cell::RefCell;
688    use std::rc::Rc;
689
690    #[test]
691    fn strict_prepare_still_rejects_builtin_after_warm() {
692        let dir = tempfile::tempdir().unwrap();
693        std::fs::write(dir.path().join("avril.conf"), "").unwrap();
694        std::fs::write(
695            dir.path().join("parent.mjs"),
696            "import './child.mjs';\nexport default 1;\n",
697        )
698        .unwrap();
699        std::fs::write(
700            dir.path().join("child.mjs"),
701            "import 'node:fs';\nimport './sibling.mjs';\nexport default 2;\n",
702        )
703        .unwrap();
704        std::fs::write(dir.path().join("sibling.mjs"), "export default 3;\n").unwrap();
705        let (_runtime, graph) = crate::modules::offline_graph(dir.path());
706        let warned = Rc::new(RefCell::new(Vec::new()));
707        let warn = {
708            let warned = warned.clone();
709            Rc::new(move |specifier: &str, referrer: &str| {
710                warned
711                    .borrow_mut()
712                    .push((specifier.to_owned(), referrer.to_owned()));
713            })
714        };
715        let parent = dir.path().join("parent.mjs").to_string_lossy().into_owned();
716        futures::executor::block_on(graph.prepare_for_add(parent.clone(), String::new(), warn))
717            .unwrap();
718        assert!(
719            warned.borrow().iter().any(|(specifier, referrer)| {
720                specifier == "node:fs" && referrer.ends_with("child.mjs")
721            }),
722            "{warned:?}",
723        );
724        let error = futures::executor::block_on(graph.prepare(parent, String::new())).unwrap_err();
725        assert!(
726            error
727                .to_string()
728                .contains("builtin modules are not supported"),
729            "{error:?}"
730        );
731    }
732}