Skip to main content

bynk_project/
discovery.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use bynk_syntax::ast::{
6    AdapterDecl, Case, Commons, CommonsItem, ConsumesDecl, ExportsDecl, SourceUnit, SuiteDecl,
7    TestTier, Trivia, TypeRef, UsesDecl,
8};
9use bynk_syntax::error::CompileError;
10use bynk_syntax::lexer;
11use bynk_syntax::parser;
12use bynk_syntax::span::Span;
13
14use crate::roots::{Roots, UnitKind};
15
16/// v0.118: a case's *effective* tier — its own `as <tier>`, else the suite
17/// default, else `unit`.
18pub fn case_effective_tier(case: &Case, suite: &SuiteDecl) -> TestTier {
19    case.tier.or(suite.tier).unwrap_or(TestTier::Unit)
20}
21
22/// v0.118: whether a suite's *effective* tier is `system` — the suite default
23/// is `system`, or any case opts up to `system`. Such a suite is emitted via
24/// the wired cross-Worker (`Integration`) machinery; otherwise it stays
25/// in-process (`Test`).
26pub fn suite_effective_tier_is_system(suite: &SuiteDecl) -> bool {
27    suite.tier == Some(TestTier::System)
28        || suite.cases.iter().any(|c| c.tier == Some(TestTier::System))
29}
30
31/// Read a source file from the overlay (keyed by canonicalised absolute
32/// path; falls back to the literal path so a not-yet-created overlay entry
33/// still matches). Every caller into this module now supplies a complete
34/// overlay — content-ownership track (#1086) slice 5 removed the disk-read
35/// fallback this used to have on a miss, so an incomplete overlay is a real
36/// `NotFound` error here, not a silent disk read.
37///
38/// Finding #55/#65: tries the literal path first, `canonicalize()` only on a
39/// miss — an in-memory/wasm project's synthetic overlay keys never exist on
40/// disk, so `canonicalize()` was a guaranteed-failing syscall on every read of
41/// every such file, for no benefit (the literal-path lookup below already
42/// finds the same entry).
43pub fn read_source(path: &Path, overlay: &HashMap<PathBuf, String>) -> std::io::Result<String> {
44    if let Some(text) = overlay.get(path) {
45        return Ok(text.clone());
46    }
47    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
48    if let Some(text) = overlay.get(&canonical) {
49        return Ok(text.clone());
50    }
51    Err(std::io::Error::new(
52        std::io::ErrorKind::NotFound,
53        format!("no overlay entry for `{}`", path.display()),
54    ))
55}
56
57/// An adapter's `.binding.ts` module: overlay-first (an open, unsaved
58/// binding buffer), else a real disk read. Content-ownership track (#1086)
59/// scope note, found under slice 5's implementation: unlike a project's
60/// `.bynk` sources — enumerable ahead of time by extension, and this
61/// track's actual charter — a binding module's *path* is only known once
62/// its declaring adapter has been parsed (`adapter … { binding: "…" }`), so
63/// no discovery walk (`bynk-testkit`, `bynk-driver::discovery`) can
64/// pre-populate it into a sources map the way `.bynk` files are. Keeping a
65/// disk-read fallback here — the CLI's real production path has always
66/// worked exactly this way, `#1077`/`#1081` notwithstanding — is a
67/// deliberate, narrow carve-out, not a straggler.
68pub fn read_adapter_binding(
69    path: &Path,
70    overlay: &HashMap<PathBuf, String>,
71) -> std::io::Result<String> {
72    if let Some(text) = overlay.get(path) {
73        return Ok(text.clone());
74    }
75    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
76    if let Some(text) = overlay.get(&canonical) {
77        return Ok(text.clone());
78    }
79    fs::read_to_string(path)
80}
81
82/// A parsed `.bynk` file: its source, AST, and the two path forms it needs.
83///
84/// Slice 0: `source_path` and `identity_path` are **different things**, and
85/// conflating them is what made a two-root project's file identity ambiguous.
86/// They coincide for a single-root project, which is why one field sufficed
87/// until `include` could hold two entries.
88///
89/// P4.0 (#1113, [DECISION B]): fields are crate-private now that `ParsedFile`
90/// lives in `bynk-project` — `bynk-emit`'s `symbols`/`validate` read them
91/// through the accessors below instead of the direct field pokes a
92/// same-crate `pub(crate)` allowed before the move.
93#[derive(Clone)]
94pub struct ParsedFile {
95    /// The path **relative to the `include` root that contains this file** —
96    /// the form unit validation requires. `src/todos.bynk` under the `src`
97    /// root is `todos.bynk`, which is what lets it declare `context todos`
98    /// ([`crate::paths::unit_path_matches`], via
99    /// [`crate::consistency::check_path_name_alignment`]). Prefixing this
100    /// would make every unit in every project fail alignment.
101    pub(crate) source_path: PathBuf,
102    /// Slice 0: the path **relative to the project root** — this file's
103    /// identity, unique across `include` roots. `src/todos.bynk` and
104    /// `tests/todos.bynk` share a `source_path` (`todos.bynk`) but differ
105    /// here. Everything that *keys* a file — the analysed snapshots, the
106    /// diagnostic attribution — uses this; nothing that *validates a unit's
107    /// name* may.
108    ///
109    /// Equal to `source_path` for a single-root project (`Roots::Single`
110    /// resolves to one tree with an empty prefix), so single-root behaviour
111    /// is unchanged by construction.
112    pub(crate) identity_path: PathBuf,
113    /// v0.72: the absolute path the compiler read this file from, used as the
114    /// source-map `sources` entry so an editor's breakpoint (set on the real
115    /// `.bynk` file) resolves to the same path the debugger loads. `None` for
116    /// toolchain-injected synthetic units, which have no on-disk source.
117    pub(crate) abs_path: Option<PathBuf>,
118    pub(crate) source: String,
119    pub(crate) unit: SourceUnit,
120    pub(crate) kind: UnitKind,
121    /// v0.17: true for toolchain-injected units (the `bynk` surface) — exempt
122    /// from the reserved-namespace and missing-binding checks.
123    pub(crate) synthetic: bool,
124}
125
126impl ParsedFile {
127    /// Construct directly — used by `bynk-emit`'s first-party synthetic-unit
128    /// injection (`firstparty_parsed`), which builds a `ParsedFile` for a
129    /// toolchain-supplied source (`bynk.bynk`, `bynk.cloudflare`, …) that
130    /// never went through [`parse_sources`]'s discovery-driven path.
131    pub fn synthetic(
132        identity_path: PathBuf,
133        source_path: PathBuf,
134        source: String,
135        unit: SourceUnit,
136        kind: UnitKind,
137    ) -> Self {
138        ParsedFile {
139            source_path,
140            identity_path,
141            abs_path: None,
142            source,
143            unit,
144            kind,
145            synthetic: true,
146        }
147    }
148
149    /// General constructor — `bynk-emit`'s own tests use this to build a
150    /// hand-rolled `ParsedFile` fixture (a specific `source_path`/
151    /// `identity_path` pair, a non-synthetic unit) that neither
152    /// [`Self::synthetic`] (forces `source_path == identity_path`,
153    /// `synthetic: true`) nor [`parse_sources`] (needs a real token stream)
154    /// fits.
155    #[allow(clippy::too_many_arguments)]
156    pub fn new(
157        source_path: PathBuf,
158        identity_path: PathBuf,
159        abs_path: Option<PathBuf>,
160        source: String,
161        unit: SourceUnit,
162        kind: UnitKind,
163        synthetic: bool,
164    ) -> Self {
165        ParsedFile {
166            source_path,
167            identity_path,
168            abs_path,
169            source,
170            unit,
171            kind,
172            synthetic,
173        }
174    }
175
176    /// The path **relative to the `include` root that contains this file**.
177    pub fn source_path(&self) -> PathBuf {
178        self.source_path.clone()
179    }
180
181    /// The path **relative to the project root** — this file's identity,
182    /// unique across `include` roots. See the field's own doc for why this
183    /// and [`Self::source_path`] must not be conflated.
184    pub fn identity_path(&self) -> PathBuf {
185        self.identity_path.clone()
186    }
187
188    /// The absolute path this file was read from, when it has one — `None`
189    /// for toolchain-injected synthetic units.
190    pub fn abs_path(&self) -> Option<PathBuf> {
191        self.abs_path.clone()
192    }
193
194    pub fn kind(&self) -> UnitKind {
195        self.kind
196    }
197
198    /// Override the discovered kind — `bynk-emit`'s own tests use this to
199    /// build a scenario's intermediate unit as a commons regardless of what
200    /// AST shape (`context_using`, …) constructed it, without needing a
201    /// second builder per kind.
202    pub fn set_kind(&mut self, kind: UnitKind) {
203        self.kind = kind;
204    }
205
206    /// True for toolchain-injected units (the `bynk` surface).
207    pub fn is_synthetic(&self) -> bool {
208        self.synthetic
209    }
210
211    pub fn unit(&self) -> &SourceUnit {
212        &self.unit
213    }
214
215    /// Mutable access to the parsed unit — `bynk-emit`'s
216    /// `normalize_service_defaults` (service `by`/`given` default injection)
217    /// is the one caller that rewrites a unit's items in place, ahead of
218    /// grouping/checking.
219    pub fn unit_mut(&mut self) -> &mut SourceUnit {
220        &mut self.unit
221    }
222
223    /// The raw source text this file was parsed from.
224    pub fn source(&self) -> &str {
225        &self.source
226    }
227
228    /// v0.72: the source-map `sources` entry for this file — the absolute path
229    /// the compiler read it from (forward slashes), so an editor breakpoint set
230    /// on the real `.bynk` resolves to the same path the debugger loads. A
231    /// project-relative name would resolve against the emitted `.ts`'s directory,
232    /// which is the wrong place. Synthetic units (no on-disk source) fall back to
233    /// their relative path.
234    pub fn map_source_name(&self) -> String {
235        self.abs_path
236            .as_deref()
237            .unwrap_or(self.source_path.as_path())
238            .to_string_lossy()
239            .replace('\\', "/")
240    }
241
242    pub fn items(&self) -> &Vec<CommonsItem> {
243        match &self.unit {
244            SourceUnit::Commons(c) => &c.items,
245            SourceUnit::Context(c) => &c.items,
246            SourceUnit::Adapter(a) => &a.items,
247            SourceUnit::Suite(_) => {
248                // Tests don't contribute CommonsItem items; the production
249                // pipeline never asks them to. Return a singleton empty vec.
250                static EMPTY: std::sync::OnceLock<Vec<CommonsItem>> = std::sync::OnceLock::new();
251                EMPTY.get_or_init(Vec::new)
252            }
253        }
254    }
255
256    /// P6.x (#1137): does this file declare a `messages { … }` block? The one
257    /// predicate a `messages`-bundle emitter needs — whether to inject the
258    /// `bynk.locale` `render` fallback import — without the caller having to
259    /// walk [`items`](Self::items) and match [`CommonsItem::Messages`] itself.
260    pub fn declares_messages(&self) -> bool {
261        self.items()
262            .iter()
263            .any(|it| matches!(it, CommonsItem::Messages(_)))
264    }
265
266    pub fn uses(&self) -> &Vec<UsesDecl> {
267        match &self.unit {
268            SourceUnit::Commons(c) => &c.uses,
269            SourceUnit::Context(c) => &c.uses,
270            SourceUnit::Adapter(a) => &a.uses,
271            SourceUnit::Suite(t) => &t.uses,
272        }
273    }
274
275    pub fn consumes(&self) -> &[ConsumesDecl] {
276        match &self.unit {
277            SourceUnit::Commons(_) => &[],
278            SourceUnit::Context(c) => &c.consumes,
279            // v0.18: adapter-to-adapter capability dependencies (spec §4.5).
280            SourceUnit::Adapter(a) => &a.consumes,
281            // An integration test's participant edges are resolved separately
282            // (the harness root consumes every participant); it has no
283            // `consumes` of its own.
284            SourceUnit::Suite(_) => &[],
285        }
286    }
287
288    /// `exports` clauses, for the unit kinds that have them (contexts and
289    /// adapters). Empty for commons/tests.
290    pub fn exports(&self) -> &[ExportsDecl] {
291        match &self.unit {
292            SourceUnit::Context(c) => &c.exports,
293            SourceUnit::Adapter(a) => &a.exports,
294            _ => &[],
295        }
296    }
297
298    pub fn adapter(&self) -> Option<&AdapterDecl> {
299        match &self.unit {
300            SourceUnit::Adapter(a) => Some(a),
301            _ => None,
302        }
303    }
304
305    pub fn test(&self) -> Option<&SuiteDecl> {
306        match &self.unit {
307            SourceUnit::Suite(t) => Some(t),
308            _ => None,
309        }
310    }
311
312    /// v0.119 (ADR 0155): the agent names this file's own `for all run:
313    /// History[Agent]` properties drive — `emit_agent` gates the exported
314    /// `__bynkDriveHistory_<Agent>` driver on membership. Empty for a
315    /// non-suite file, or a suite with no such property.
316    pub fn history_target_agent_names(&self) -> impl Iterator<Item = &str> {
317        self.test()
318            .into_iter()
319            .flat_map(|t| &t.properties)
320            .flat_map(|prop| &prop.forall.bindings)
321            .filter_map(|b| match &b.type_ref {
322                TypeRef::History(inner, _) => match inner.as_ref() {
323                    TypeRef::Named(id) => Some(id.name.as_str()),
324                    _ => None,
325                },
326                _ => None,
327            })
328    }
329
330    /// v0.118: a suite whose *effective* tier is `system` is emitted through
331    /// the wired cross-Worker machinery (the retired standalone `integration`
332    /// path, now re-driven from tiers). Returns the underlying [`SuiteDecl`]
333    /// when this file is such a suite.
334    pub fn integration(&self) -> Option<&SuiteDecl> {
335        match &self.unit {
336            SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => Some(t),
337            _ => None,
338        }
339    }
340
341    /// Build a synthetic Commons AST node carrying the given items, so the
342    /// existing resolver/checker pipeline can be driven uniformly.
343    pub fn as_synthetic_commons(&self, items: Vec<CommonsItem>) -> Commons {
344        let (name, uses, documentation, form, span) = match &self.unit {
345            SourceUnit::Commons(c) => (
346                c.name.clone(),
347                c.uses.clone(),
348                c.documentation.clone(),
349                c.form,
350                c.span,
351            ),
352            SourceUnit::Context(c) => (
353                c.name.clone(),
354                c.uses.clone(),
355                c.documentation.clone(),
356                c.form,
357                c.span,
358            ),
359            SourceUnit::Suite(t) => (
360                t.target.clone(),
361                t.uses.clone(),
362                t.documentation.clone(),
363                t.form,
364                t.span,
365            ),
366            SourceUnit::Adapter(a) => (
367                a.name.clone(),
368                a.uses.clone(),
369                a.documentation.clone(),
370                a.form,
371                a.span,
372            ),
373        };
374        Commons {
375            name,
376            items,
377            uses,
378            documentation,
379            form,
380            span,
381            trivia: Trivia::default(),
382            trailing_comments: Vec::new(),
383        }
384    }
385}
386
387/// Parse already-read source text into a [`ParsedFile`]. The read happens
388/// at the call site (v0.24): the pipeline owns the text for snapshots and
389/// per-file error attribution, and the overlay supplies unsaved buffers.
390/// Slice 0: `prefix` is this tree's project-root-relative `include` prefix
391/// (`src`, `tests`, …), empty for a single-root project. It builds each file's
392/// `identity_path`; `source_path` stays relative to `root` (the tree), which is
393/// what unit validation reads. See [`ParsedFile`].
394pub fn parse_sources(
395    root: &Path,
396    prefix: &Path,
397    path: &Path,
398    source: String,
399    next_expr_id: &mut u32,
400    next_file_id: &mut u32,
401) -> Result<(Vec<ParsedFile>, Vec<CompileError>), Vec<CompileError>> {
402    // T3.5 (R2.2): one `FileId` per file this project parse touches, allocated
403    // here (the same choke point `next_expr_id` uses) rather than by the
404    // caller, so every span the lexer stamps for this file carries a real,
405    // distinct file identity instead of `FileId::UNKNOWN`.
406    let file = bynk_syntax::span::FileId(*next_file_id);
407    *next_file_id += 1;
408    let tokens = lexer::tokenize_in(&source, file).map_err(|e| vec![e])?;
409    // v0.113: a file may declare more than one top-level unit — an *atomic*
410    // file holding `commons`/`context` alongside a `suite` (DECISION S). Each
411    // unit becomes its own `ParsedFile` sharing the file's source and path, so
412    // the downstream grouping partitions *declarations* by kind: the source
413    // units flow to the build, the suites to `bynkc test` only.
414    // ADR 0117: a warning-severity parse diagnostic (an orphan doc block)
415    // must not hard-fail discovery — the parsed units flow to the build and
416    // the warnings ride out to the caller's severity-aware sink.
417    // T3.4 (R2.4): `next_expr_id` continues one `ExprId` counter across every
418    // file `phase_parse` parses in this project, not just this one file — a
419    // multi-file commons later merges sibling files' methods into one
420    // `check_record` call (`collect_unit_methods`), and two independently
421    // zero-based files would otherwise collide on the same id in the same
422    // `expr_types` map. Caught live by finding #28's debug assertion on
423    // `bynkc/tests/fixtures/positive/64_full_time_commons` before this fix.
424    let (units, warnings) = parser::parse_units_with_warnings_from(&tokens, &source, next_expr_id)?;
425    let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
426    // v0.72: store an *absolute* path — `path` is relative when the compiler
427    // was invoked with a relative input (`bynkc test .`), and a relative map
428    // `source` would resolve against the emitted `.ts`'s directory, not the
429    // real file. `std::path::absolute` resolves against cwd without touching
430    // the filesystem (so it works for not-yet-saved overlay buffers too).
431    let abs_path = std::path::absolute(path).ok();
432    let files = units
433        .into_iter()
434        .map(|unit| {
435            let kind = match &unit {
436                SourceUnit::Commons(_) => UnitKind::Commons,
437                SourceUnit::Context(_) => UnitKind::Context,
438                // v0.118: a suite whose effective tier is `system` is emitted
439                // through the wired cross-Worker machinery (classified as
440                // `Integration`); unit/integration-tier suites stay in-process.
441                SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => UnitKind::Integration,
442                SourceUnit::Suite(_) => UnitKind::Test,
443                SourceUnit::Adapter(_) => UnitKind::Adapter,
444            };
445            ParsedFile {
446                abs_path: abs_path.clone(),
447                identity_path: prefix.join(&rel),
448                source_path: rel.clone(),
449                source: source.clone(),
450                unit,
451                kind,
452                synthetic: false,
453            }
454        })
455        .collect();
456    Ok((files, warnings))
457}
458
459pub fn discover_bynk_files(
460    root: &Path,
461    excludes: &[PathBuf],
462) -> Result<Vec<PathBuf>, CompileError> {
463    if !root.exists() {
464        return Err(CompileError::new(
465            "bynk.project.no_root",
466            Span::default(),
467            format!("project root does not exist: {}", root.display()),
468        ));
469    }
470    // v0.113: skip excluded subtrees (author `exclude` + the tool's own caches)
471    // and hidden directories, so an `include` root at the project root does not
472    // sweep up generated, vendored, or dot-directory `.bynk`.
473    let is_excluded = |dir: &Path| {
474        excludes.iter().any(|ex| dir == ex || dir.starts_with(ex))
475            || dir
476                .file_name()
477                .and_then(|n| n.to_str())
478                .is_some_and(|n| n.starts_with('.') && n != ".")
479    };
480    let mut out = Vec::new();
481    let mut stack = vec![root.to_path_buf()];
482    while let Some(dir) = stack.pop() {
483        let rd = match fs::read_dir(&dir) {
484            Ok(r) => r,
485            Err(e) => {
486                return Err(CompileError::new(
487                    "bynk.project.read_failed",
488                    Span::default(),
489                    format!("could not read directory `{}`: {e}", dir.display()),
490                ));
491            }
492        };
493        for entry in rd.flatten() {
494            let p = entry.path();
495            if p.is_dir() {
496                if !is_excluded(&p) {
497                    stack.push(p);
498                }
499            } else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
500                out.push(p);
501            }
502        }
503    }
504    out.sort();
505    Ok(out)
506}
507
508/// Slice A: the `.bynk` files these roots contain — the **same walk**
509/// `compile_project` performs, honouring `exclude` and the tool's own `out`/
510/// `node_modules` caches.
511///
512/// P4.2 (#1122, Decision B): moved here from `bynk-emit/src/project.rs` — its
513/// body called only `bynk-project`-local functions already, with no
514/// `bynk-emit`-specific state. `bynk-emit` re-exports it at its existing
515/// `bynk_emit::project::discover_project_files` path so `read_disk_sources`
516/// and `bynk-testkit` need no edit; `bynk-ide` calls this path directly.
517pub fn discover_project_files(roots: &Roots) -> Vec<PathBuf> {
518    let trees = roots.trees();
519    let excludes = roots.excludes();
520    let mut out = Vec::new();
521    for (root, _prefix) in &trees {
522        // Every tree past the first is optional — a project may simply have
523        // no such subtree (R3.9, #1113: every `include` entry is walked, not
524        // just the first two). `unwrap_or_default` already treats a missing
525        // root the same as "no files here" for every tree, first included —
526        // no need to `root.exists()` before calling `discover_bynk_files`
527        // (itself a `fs::read_dir`) just to decide whether to call it: that
528        // would cost a redundant `stat()` per tree for the same answer.
529        out.extend(discover_bynk_files(root, &excludes).unwrap_or_default());
530    }
531    out.sort();
532    out.dedup();
533    out
534}
535
536pub fn check_file_directory_conflicts(
537    root: &Path,
538    files: &[PathBuf],
539) -> Result<(), Vec<CompileError>> {
540    let mut errors: Vec<CompileError> = Vec::new();
541    let mut bynk_files: HashSet<PathBuf> = HashSet::new();
542    let mut dirs_with_bynk: HashSet<PathBuf> = HashSet::new();
543    for p in files {
544        let rel = p.strip_prefix(root).unwrap_or(p);
545        bynk_files.insert(rel.to_path_buf());
546        if let Some(parent) = rel.parent() {
547            dirs_with_bynk.insert(parent.to_path_buf());
548        }
549    }
550    for f in &bynk_files {
551        let stem = f.with_extension("");
552        if dirs_with_bynk.contains(&stem) {
553            errors.push(
554                CompileError::new(
555                    "bynk.project.file_and_directory",
556                    Span::default(),
557                    format!(
558                        "commons at `{}` is ambiguous: both `{}` and `{}/` exist with `.bynk` content",
559                        f.with_extension("").display(),
560                        f.display(),
561                        stem.display()
562                    ),
563                )
564                .with_note(
565                    "a commons can be a single `.bynk` file OR a directory of `.bynk` files, not both",
566                ),
567            );
568        }
569    }
570    if errors.is_empty() {
571        Ok(())
572    } else {
573        Err(errors)
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    /// Finding #55/#65: a synthetic path that never exists on disk (the
582    /// in-memory/wasm case) must still resolve via the overlay's literal-path
583    /// entry — `canonicalize()` on such a path always fails, so the fix tries
584    /// the literal path first rather than paying for that failing syscall on
585    /// every read.
586    #[test]
587    fn read_source_finds_a_synthetic_overlay_path_that_does_not_exist_on_disk() {
588        let path = PathBuf::from("./__bynk_in_memory__/t.bynk");
589        let mut overlay = HashMap::new();
590        overlay.insert(path.clone(), "context t\n".to_string());
591        let got = read_source(&path, &overlay).expect("the overlay entry must be found");
592        assert_eq!(got, "context t\n");
593    }
594
595    /// Content-ownership track (#1086) slice 5: a real on-disk file with no
596    /// overlay entry must now error, never silently fall back to reading it
597    /// off disk — the disk-read fallback this test guards the absence of was
598    /// deleted in this slice; every caller supplies a complete overlay.
599    #[test]
600    fn read_source_errors_on_a_real_file_with_no_overlay_entry_rather_than_reading_disk() {
601        let dir = std::env::temp_dir().join(format!(
602            "bynk-emit-discovery-fallback-test-{}",
603            std::process::id()
604        ));
605        std::fs::create_dir_all(&dir).expect("create test dir");
606        let path = dir.join("t.bynk");
607        std::fs::write(&path, "context t\n").expect("write real file");
608        let got = read_source(&path, &HashMap::new());
609        std::fs::remove_dir_all(&dir).ok();
610        assert!(
611            got.is_err(),
612            "a real file with no overlay entry must not be silently read from disk"
613        );
614        assert_eq!(got.unwrap_err().kind(), std::io::ErrorKind::NotFound);
615    }
616}