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