Skip to main content

brink_environment/
lib.rs

1//! The compilation **environment as a deterministic input** (#1306).
2//!
3//! brink already compiles as a pure query over salsa inputs, so a determinism
4//! boundary *exists* — but the inputs were only ever pushed imperatively
5//! (`set_file`/`set_entry`/`set_analysis_options`) with no nameable value to
6//! hold, hash, serialize, cache on, or diff. This crate reifies that boundary:
7//!
8//! - [`Environment`] is the **pure input value** — a serializable,
9//!   content-addressed reification of "the sources being compiled + the
10//!   resolved policy + the (reserved) resolved dependency set + the entry."
11//!   It is the whole compilation universe as a single hashable artifact.
12//! - [`Project::load`] is the **effectful producer** — mount-specific, where
13//!   all ambient reads (a filesystem walk, a drained `AssetReader`, an LSP
14//!   store) and future dependency resolution live. It walks a
15//!   [`SourceTree`](brink_source_tree::SourceTree), reads + hashes the
16//!   sources, discovers + parses `brink.toml` over the *same* tree, applies
17//!   override precedence, and freezes an [`Environment`].
18//! - [`compile`] is the **pure function over the input** — it seeds a fresh
19//!   salsa `ProjectDb` from an [`Environment`] and pulls the memoized
20//!   `story_data` query. No ambient reads, no walk-up, no I/O: everything it
21//!   needs is already in the frozen value.
22//!
23//! ```text
24//! [mount-specific resolution]   →   Environment          →   compile(&Environment)
25//!  RealFs walk / drained            reified, content-        PURE, deterministic
26//!  AssetReader / LSP store          addressed input value    (no ambient reads)
27//!  = Project::load (effectful)      {sources+hashes,         = salsa query pull
28//!                                    config, deps, entry}
29//! ```
30//!
31//! The `Environment` is serialized/reified **now**, not deferred to when
32//! external libraries arrive (ruled 2026-07-23): the boundary's whole value is
33//! its explicitness — a reproducible, hashable ([`Environment::content_hash`])
34//! input artifact enabling build caching, reproducible builds, and input
35//! diffing from day one. The [reserved `resolved_deps`](Environment::resolved_deps)
36//! slot (#1093) is where external module artifacts will land, mounted at
37//! compile time with the same identity/linking machinery load-time DLC/UGC
38//! modules use — designed-for, not built.
39//!
40//! [`Project::load`] also mounts the **stdlib** into the manifest (#2080,
41//! ruled 2026-08-03) — built-in preset/convention `.brink` source
42//! (`std/conventions/screenplay.brink`), embedded via `include_str!` so it
43//! mounts identically on hosts with no filesystem (wasm). The stdlib is
44//! *source*, not a [`ResolvedDep`] (that slot stays reserved for #1093's
45//! compiled per-module artifacts): it joins the manifest exactly like any
46//! project file, under the same string-key convention, so it needs no
47//! parallel identity or resolution mechanism.
48
49use std::borrow::Cow;
50use std::collections::BTreeMap;
51use std::io;
52use std::path::Path;
53use std::sync::Arc;
54
55use brink_compiler::{CompileError, CompileOutput, ResolvedDiagnostic};
56use brink_driver::{AnalysisOptions, Dialect, Driver, LintLevel, TypePolicy};
57use brink_ir::Diagnostic;
58use brink_project_config::{ConfigError, discover_from_entry_in_tree, parse_str_at};
59use brink_source_tree::SourceTree;
60
61// ── Content addressing ───────────────────────────────────────────────
62
63/// FNV-1a 64-bit — a small, dependency-free, fully deterministic hash. Chosen
64/// over `std`'s `DefaultHasher` (whose output is not guaranteed stable across
65/// Rust versions) and over pulling in a crypto crate: a content-addressed
66/// store keyed within one project's source set does not need cryptographic
67/// collision resistance, only a stable, platform-independent digest. The v2
68/// `ContentStore` swap (a persistent variant) is the natural point to revisit
69/// the digest if one is ever needed.
70fn fnv1a_64(bytes: &[u8]) -> u64 {
71    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
72    const PRIME: u64 = 0x0000_0100_0000_01b3;
73    let mut hash = OFFSET;
74    for &byte in bytes {
75        hash ^= u64::from(byte);
76        hash = hash.wrapping_mul(PRIME);
77    }
78    hash
79}
80
81/// A content hash: the deterministic digest of one source file's text. The
82/// [`Environment::manifest`] keys files by their (root-relative) path and maps
83/// each to a `ContentHash`; the [`ContentStore`] maps a `ContentHash` back to
84/// its text. Two files with byte-identical content share one hash (and one
85/// stored copy) — the store is content-addressed, so it deduplicates.
86#[derive(
87    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
88)]
89pub struct ContentHash(u64);
90
91impl ContentHash {
92    /// The content hash of `text`.
93    #[must_use]
94    pub fn of(text: &str) -> Self {
95        Self(fnv1a_64(text.as_bytes()))
96    }
97}
98
99impl std::fmt::Display for ContentHash {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        write!(f, "{:016x}", self.0)
102    }
103}
104
105/// The hash of a whole [`Environment`] — over its manifest, entry, resolved
106/// options, and resolved deps. This is the reproducible-build / build-cache
107/// key the "serialize now" ruling exists to enable: two environments with the
108/// same `EnvHash` compile to the same `StoryData`.
109#[derive(
110    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
111)]
112pub struct EnvHash(u64);
113
114impl std::fmt::Display for EnvHash {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(f, "{:016x}", self.0)
117    }
118}
119
120/// Where an [`Environment`]'s source text actually lives — the seam that makes
121/// the future content-cache an impl swap, not an API break.
122///
123/// v1 is [`Inline`](ContentStore::Inline): the text is bundled directly into
124/// the value, so a serialized `Environment` is fully self-contained (portable,
125/// diffable, cacheable as one blob). A later v2 can add a `Persistent` variant
126/// backed by an on-disk content-addressed store; consumers are unaffected
127/// because they only ever read through [`Environment::source_text`], never a
128/// concrete store field.
129#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
130pub enum ContentStore {
131    /// Self-contained: every hash maps to its text, bundled in the value.
132    Inline(BTreeMap<ContentHash, String>),
133}
134
135impl ContentStore {
136    /// The text for `hash`, if this store holds it.
137    fn get(&self, hash: ContentHash) -> Option<&str> {
138        match self {
139            Self::Inline(map) => map.get(&hash).map(String::as_str),
140        }
141    }
142}
143
144/// A resolved external module artifact (a library) — **reserved** (#1093).
145///
146/// Empty in v1: external libraries are not on the roadmap. The slot exists so
147/// that when they arrive, dependency *resolution* (ambient + pinned, on the
148/// producer side, à la `Cargo.lock`) freezes its *resolved set* into the
149/// `Environment`, keeping compilation pure. `the-tree-is-the-universe`
150/// generalizes to `the-environment-is-the-universe`:
151/// `{ local module tree } + { resolved external module set }`.
152#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153pub struct ResolvedDep {
154    /// The stable `(module, name)`-style identity of the resolved artifact.
155    /// A placeholder field so the reserved struct is nameable and
156    /// round-trippable; its shape is defined when #1093 is built.
157    pub module: String,
158}
159
160// ── The pure input value ─────────────────────────────────────────────
161
162/// The reified, content-addressed compilation input — the determinism
163/// boundary (#1306).
164///
165/// Everything [`compile`] needs is frozen here: the source set (as a
166/// path→hash [`manifest`](Self::manifest) plus a hash→text
167/// [`content`](Self::content) store), the designated [`entry`](Self::entry),
168/// the fully [resolved `options`](Self::options), and the reserved
169/// [`resolved_deps`](Self::resolved_deps). Because it is a plain serializable
170/// value, it can be hashed ([`content_hash`](Self::content_hash)), cached on,
171/// diffed, and round-tripped.
172///
173/// Consumers read sources **only** through [`source_keys`](Self::source_keys)
174/// and [`source_text`](Self::source_text) — never a public sources field.
175/// That is the whole point of the hash-addressed shape: the inline store can
176/// later become a persistent one with no breaking migration.
177#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
178pub struct Environment {
179    /// key (root-relative, forward-slash) → content hash. Deterministic
180    /// (`BTreeMap`); native module identity derives from these keys.
181    /// Hash-addressed from day one.
182    manifest: BTreeMap<String, ContentHash>,
183    /// The text backing store. v1 = [`ContentStore::Inline`]: text bundled,
184    /// self-contained. A v2 `Persistent` variant swaps in later; consumers,
185    /// reading only through [`source_text`](Self::source_text), are unchanged.
186    content: ContentStore,
187    /// Designated entry key — its top-level content is the start flow
188    /// (compilation universe != execution entry, #1296).
189    pub entry: String,
190    /// The **resolved** effective policy — the producer already applied
191    /// override precedence (CLI/API > `brink.toml` > default), so [`compile`]
192    /// does no further resolution.
193    pub options: AnalysisOptions,
194    /// Reserved (#1093): resolved external module artifacts (libraries).
195    /// Empty in v1.
196    pub resolved_deps: Vec<ResolvedDep>,
197}
198
199impl Environment {
200    /// Every source key (root-relative path), in deterministic sorted order.
201    pub fn source_keys(&self) -> impl Iterator<Item = &str> {
202        self.manifest.keys().map(String::as_str)
203    }
204
205    /// The source text for `key`, resolved key → hash → store.
206    ///
207    /// Returns [`Cow`] (not `&str`) so the accessor signature is identical for
208    /// the inline store (borrows) and a future persistent store (which would
209    /// own the read-back text). `None` if `key` is not in the manifest.
210    pub fn source_text(&self, key: &str) -> Option<Cow<'_, str>> {
211        let hash = *self.manifest.get(key)?;
212        self.content.get(hash).map(Cow::Borrowed)
213    }
214
215    /// The hash of the whole environment — over manifest + entry + options +
216    /// deps. A reproducible-build / cache key (see [`EnvHash`]).
217    pub fn content_hash(&self) -> EnvHash {
218        let mut buf: Vec<u8> = Vec::new();
219        for (key, hash) in &self.manifest {
220            buf.extend_from_slice(key.as_bytes());
221            buf.push(0);
222            buf.extend_from_slice(&hash.0.to_le_bytes());
223        }
224        buf.push(0xff);
225        buf.extend_from_slice(self.entry.as_bytes());
226        buf.push(0xff);
227        // serde_json is deterministic for these value shapes (BTreeMap is
228        // sorted; the option/dep structs are field-ordered), so this is a
229        // stable digest of the resolved policy + reserved deps.
230        buf.extend_from_slice(&serde_json::to_vec(&self.options).unwrap_or_default());
231        buf.push(0xff);
232        buf.extend_from_slice(&serde_json::to_vec(&self.resolved_deps).unwrap_or_default());
233        EnvHash(fnv1a_64(&buf))
234    }
235}
236
237// ── Producer-side policy overrides ───────────────────────────────────
238
239/// Explicit policy a mount supplies that **wins over `brink.toml`** — the
240/// `CLI/API > file > default` precedence rule (#1005). A field left `None`
241/// (or, for `lints`, absent from the map) means "the caller has no explicit
242/// value," so the discovered `brink.toml` (or the default) governs that
243/// field.
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
245pub struct OptionOverrides {
246    /// An explicit dialect (e.g. the CLI `--dialect` flag), if the caller set
247    /// one.
248    pub dialect: Option<Dialect>,
249    /// An explicit type policy (e.g. the CLI `--types` flag), if set.
250    pub types: Option<TypePolicy>,
251    /// Explicit per-code lint-level overrides (e.g. the CLI's repeatable
252    /// `--deny`/`--warn`/`--allow <CODE>` flags, issue #1373), keyed by the
253    /// diagnostic code's string form. Always wins over the same code in a
254    /// discovered `brink.toml`'s `[lints]` table — folded in by
255    /// [`AnalysisOptions::apply_lint_overrides`], which validates each code
256    /// the same way the file's table is validated (#1160: only codes whose
257    /// *default* severity is `Warning` are overridable).
258    pub lints: BTreeMap<String, LintLevel>,
259    /// An explicit `deny-warnings` (e.g. the CLI's `-D warnings` flag), if
260    /// set. `None` means "the caller has no explicit value," same as
261    /// `dialect`/`types`.
262    pub deny_warnings: Option<bool>,
263    /// D6 (`docs/debugger-spec.md` §1.2/§2, issue #3184): the CLI's
264    /// explicit `--debug-info` flag. Unlike `dialect`/`types`/
265    /// `deny_warnings`, this is a plain `bool` rather than `Option<bool>` —
266    /// there is no `brink.toml` project-file spelling for it to defer to
267    /// (the ship-policy ruling scopes this to mount-time compiles only), so
268    /// there is no "caller didn't say" case to distinguish from "caller
269    /// said off." `false` (the `Default`) never turns the section on.
270    pub debug_info: bool,
271}
272
273// ── The effectful producer ───────────────────────────────────────────
274
275/// The producer namespace: `Project::load` turns a mount (a
276/// [`SourceTree`](brink_source_tree::SourceTree)) into an [`Environment`].
277///
278/// `Project` is where every ambient/effectful concern lives (filesystem
279/// walks, an `AssetReader` drain, an LSP store, and — later — dependency
280/// resolution), quarantined off the pure [`Environment`] it produces.
281pub struct Project;
282
283impl Project {
284    /// Walk `tree`, read + hash its sources, discover + parse `brink.toml`
285    /// over the same tree, apply override precedence, and freeze an
286    /// [`Environment`].
287    ///
288    /// The tree is treated as rooted at `.` with root-relative keys (the
289    /// #1312 `SourceTree` config-discovery convention): `entry` is a
290    /// root-relative key, and any `brink.toml` is looked up by a direct
291    /// `{ancestor}/brink.toml` probe walking up from `entry` (#1370 —
292    /// discovery no longer calls `list` at all, so a mount's `list` is not
293    /// required to surface `brink.toml`). The mount is responsible for
294    /// rooting its tree (the CLI drains its project root into an in-memory
295    /// tree; web / LSP push their own root-relative store).
296    ///
297    /// **Sync** — the only asynchrony a mount has (e.g. a bevy `AssetReader`)
298    /// is quarantined in *building the tree*, before this runs, matching the
299    /// `InkLoader` drain-then-compile pattern.
300    ///
301    /// Dispatches on `entry`'s extension: a `.brink` entry's universe is the
302    /// whole native source tree (enumerate every `.brink` key); a `.ink` entry
303    /// follows its `INCLUDE` graph from the entry (a BFS over the tree's
304    /// reads).
305    ///
306    /// ## Repeat compiles are deterministic
307    ///
308    /// Each call resolves `AnalysisOptions` from a brand-new
309    /// `AnalysisOptions::default()` (see `resolve_options` below) — never one
310    /// left over from a previous call — so two sequential `load` calls for
311    /// unrelated projects never leak `dialect`/`types` between them, even
312    /// though [`AnalysisOptions::apply_project_config`]'s "unset means
313    /// untouched" rule for those two fields would otherwise let a stale
314    /// value silently survive (see that method's own "must be fresh"
315    /// invariant doc). This matters for any caller that calls `load`
316    /// repeatedly against different mounts in the same process — notably
317    /// `bevy-brink`'s `InkLoader`, invoked once per `.ink` asset (re)load —
318    /// where a leaked `dialect` would make the *n*-th load's outcome depend
319    /// on what the (*n*-1)-th load happened to resolve. Pinned by
320    /// `repeat_compiles_do_not_leak_options_across_project_load_calls`
321    /// below.
322    pub fn load(
323        tree: &dyn SourceTree,
324        entry: &str,
325        overrides: &OptionOverrides,
326    ) -> Result<Environment, LoadError> {
327        let sources = collect_sources(tree, entry)?;
328
329        let mut manifest = BTreeMap::new();
330        let mut inline = BTreeMap::new();
331        for (key, text) in sources {
332            let hash = ContentHash::of(&text);
333            inline.insert(hash, text);
334            manifest.insert(key, hash);
335        }
336
337        mount_stdlib(&mut manifest, &mut inline);
338
339        let options = resolve_options(tree, entry, overrides)?;
340
341        Ok(Environment {
342            manifest,
343            content: ContentStore::Inline(inline),
344            entry: entry.to_string(),
345            options,
346            resolved_deps: Vec::new(),
347        })
348    }
349}
350
351// ── Standard library mount (#2080) ───────────────────────────────────
352
353/// The stdlib source set, embedded at compile time. #2080's 2026-08-03
354/// ruling: `Environment` (#1306) already generalizes
355/// `the-tree-is-the-universe` to `the-environment-is-the-universe`
356/// (`{ local module tree } + { resolved external module set }`), so the
357/// stdlib needs no bespoke resolution mechanism — it mounts into the same
358/// hash-addressed [`Environment::manifest`] every project source lives in,
359/// keyed by the same root-relative, forward-slash string-key convention
360/// (`std/conventions/screenplay.brink`). Native module identity then mints
361/// `std::conventions::screenplay` for it — the reserved `std/` leading
362/// segment mints as a top-level PEER of `story`, never nested under it
363/// (issue #2245, decision-log 2026-08-04 "peer roots" ruling); every other
364/// key still mints under `story` exactly as before, so no *caller* of
365/// `brink_db::modules::native_module_path` needs a std-specific rule of its
366/// own — the peer-root check lives once, inside that function.
367///
368/// `include_str!` (not a runtime filesystem read) because the wasm build
369/// (`@brink-lang/web`) has no filesystem — the ruling's explicit
370/// instruction: "Embed the source in the binary." Each new stdlib
371/// module/preset is added here as it ships; nothing downstream changes.
372///
373/// Scope (per the ruling): this is the *mount* only. Actually importing a
374/// mounted module (`use std::…`) additionally needs #1582's pub marker and
375/// #2167's closure-scoped confinement — neither shipped yet, so a mounted
376/// module's items are not yet reachable from a project's own `use`.
377const STDLIB_SOURCES: &[(&str, &str)] = &[(
378    "std/conventions/screenplay.brink",
379    include_str!(concat!(
380        env!("CARGO_MANIFEST_DIR"),
381        "/std/conventions/screenplay.brink"
382    )),
383)];
384
385/// The stdlib source set — `(root-relative key, source text)` pairs — for a
386/// caller that builds its own analysis universe *outside* [`Project::load`]
387/// (#2198): `brink-lsp` and `brink-cli`'s `ide` subcommand handlers construct
388/// their own `Driver`/`ProjectDb` directly rather than going through
389/// [`Environment`], so they cannot reach [`mount_stdlib`]'s private
390/// manifest/content-store merge. Rather than a second, parallel mount
391/// mechanism (the "second road" failure this issue exists to close), those
392/// callers pull the identical `(key, text)` pairs from here and fold them
393/// into whatever file-registration primitive their own loader already uses
394/// (`ProjectDb::set_file`), preserving the same "a project's own file at the
395/// same key wins" precedence [`mount_stdlib`] applies. This is the single
396/// source of truth both producers read — adding a stdlib module here is
397/// still the only place a new one is registered.
398#[must_use]
399pub fn stdlib_sources() -> &'static [(&'static str, &'static str)] {
400    STDLIB_SOURCES
401}
402
403/// Merge [`STDLIB_SOURCES`] into a manifest/content pair being assembled by
404/// [`Project::load`] — the whole mechanism the ruling describes: the
405/// producer adds stdlib entries, the same way it adds any other source key.
406/// A project source already present at the same key wins over the embedded
407/// copy rather than being silently clobbered (`std/` is a reserved-by-
408/// convention path, so a real collision is not expected, but "project data
409/// always wins" costs nothing and avoids a surprising override).
410fn mount_stdlib(
411    manifest: &mut BTreeMap<String, ContentHash>,
412    inline: &mut BTreeMap<ContentHash, String>,
413) {
414    for (key, text) in STDLIB_SOURCES {
415        if manifest.contains_key(*key) {
416            continue;
417        }
418        let hash = ContentHash::of(text);
419        inline.entry(hash).or_insert_with(|| (*text).to_string());
420        manifest.insert((*key).to_string(), hash);
421    }
422}
423
424/// A native `.brink` key with a `..` segment — `native_module_path` treats
425/// `..` literally, so letting one through would mint a bogus module. Mirrors
426/// `brink_driver`'s `discover_native` guard (issue #1288 review note (a)).
427fn is_dotdot_polluted(key: &str) -> bool {
428    key.split('/').any(|segment| segment == "..")
429}
430
431/// The `.brink` native source extension.
432const NATIVE_EXTENSION: &str = "brink";
433
434/// Collect the compilation universe for `entry` as a key→source map.
435///
436/// Native (`.brink`): the tree *is* the universe — every `.brink` key it
437/// enumerates. Ink (`.ink`): the `INCLUDE`-reachable set from `entry`,
438/// discovered by reusing the driver's BFS over the tree's reads (so the ink /
439/// native discovery duplication is not re-implemented here).
440fn collect_sources(
441    tree: &dyn SourceTree,
442    entry: &str,
443) -> Result<BTreeMap<String, String>, LoadError> {
444    if brink_driver::is_native(Path::new(entry)) {
445        let mut map = BTreeMap::new();
446        for key in tree.list()? {
447            if Path::new(&key)
448                .extension()
449                .is_none_or(|ext| ext != NATIVE_EXTENSION)
450            {
451                continue;
452            }
453            if is_dotdot_polluted(&key) {
454                return Err(LoadError::InvalidSourceKey(key));
455            }
456            let text = tree.read(&key)?;
457            map.insert(key, text);
458        }
459        Ok(map)
460    } else {
461        // Reuse the driver's `INCLUDE` BFS, reading through the tree.
462        let mut driver = Driver::new();
463        driver.discover(entry, |key| tree.read(key))?;
464        let db = driver.db();
465        let mut map = BTreeMap::new();
466        for id in db.file_ids() {
467            if let (Some(path), Some(source)) = (db.file_path(id), db.source(id)) {
468                map.insert(path.to_string(), source.to_string());
469            }
470        }
471        Ok(map)
472    }
473}
474
475/// Resolve the effective [`AnalysisOptions`] for `entry`: start from the
476/// default, apply a discovered `brink.toml` (honoring override precedence),
477/// then apply the explicit overrides — including `overrides.lints`/
478/// `overrides.deny_warnings` (issue #1373), applied last (via
479/// [`AnalysisOptions::apply_lint_overrides`]) so they win over the file
480/// regardless of whether a `brink.toml` was even discovered. The one
481/// resolution point every mount inherits (#1005 precedence:
482/// `CLI/API > file > default`).
483fn resolve_options(
484    tree: &dyn SourceTree,
485    entry: &str,
486    overrides: &OptionOverrides,
487) -> Result<AnalysisOptions, LoadError> {
488    // Fresh on every call (issue #1436) — never hoisted out of this
489    // function or reused across calls. `apply_project_config`'s
490    // `dialect`/`types` fields are "unset means untouched"; starting from
491    // `default()` every time is what stops one `Project::load` call's
492    // resolved dialect/types from silently surviving into the next,
493    // unrelated one. See `Project::load`'s doc comment and
494    // `AnalysisOptions::apply_project_config`'s "must be fresh" invariant.
495    let mut options = AnalysisOptions::default();
496
497    if let Some(config_key) = discover_from_entry_in_tree(tree, entry)? {
498        let text = tree
499            .read(&config_key)
500            .map_err(|source| LoadError::ConfigRead {
501                path: config_key.clone(),
502                source,
503            })?;
504        let (config, warnings) =
505            parse_str_at(config_key.clone(), &text).map_err(|source| LoadError::Config {
506                path: config_key.clone(),
507                source: Box::new(source),
508            })?;
509        for warning in &warnings {
510            // The producer is the effectful side; surfacing unknown-key
511            // warnings here (rather than dropping them) preserves the CLI's
512            // pre-#1306 "warn, never fail" behavior — a silent drop would be a
513            // bug (house rule).
514            tracing::warn!("[{config_key}] {warning}");
515        }
516        let config_warnings = options.apply_project_config(
517            &config,
518            overrides.dialect.is_some(),
519            overrides.types.is_some(),
520        );
521        for warning in &config_warnings {
522            // Same channel as the unknown-key warnings above: an unknown or
523            // non-overridable `[lints]` code, an unrecognized `[fix]` code
524            // (issue #3447), or an unrecognized `[project] elements` preset
525            // name (issue #1874), is never silently dropped (house rule).
526            tracing::warn!("[{config_key}] {warning}");
527        }
528    }
529
530    if let Some(dialect) = overrides.dialect {
531        options.dialect = dialect;
532    }
533    if let Some(types) = overrides.types {
534        options.types = Some(types);
535    }
536    // D6 (`docs/debugger-spec.md` §1.2): no `brink.toml` spelling to defer
537    // to (see `OptionOverrides::debug_info`'s doc) — a plain unconditional
538    // assignment, not an `if let Some(...)` like `dialect`/`types` above.
539    options.emit_debug_info = overrides.debug_info;
540
541    // The top of the `CLI/API > file > default` stack (#1373): applied last
542    // so an explicit `--deny`/`--warn`/`--allow`/`-D warnings` always wins
543    // over whatever the file (or nothing, if no `brink.toml` was found) set
544    // for the same code.
545    let lint_override_warnings =
546        options.apply_lint_overrides(&overrides.lints, overrides.deny_warnings);
547    for warning in &lint_override_warnings {
548        // Same "warn, never silently drop" channel as the file-sourced
549        // warnings above (house rule).
550        tracing::warn!("{warning}");
551    }
552
553    Ok(options)
554}
555
556// ── The pure compile over the input ──────────────────────────────────
557
558/// Compile an [`Environment`] — the **pure** function over the reified input.
559///
560/// Seeds a fresh salsa `ProjectDb` from the frozen value
561/// (`set_analysis_options` + `set_file` per source key, in the manifest's
562/// deterministic sorted order, so native `FileId`s/module identity mint
563/// exactly as native discovery would + `set_entry`) and pulls the memoized
564/// `story_data` query. No ambient reads, no walk-up, no I/O.
565pub fn compile(env: &Environment) -> Result<CompileOutput, CompileError> {
566    let mut driver = Driver::new();
567    driver.set_analysis_options(env.options.clone());
568
569    for key in env.source_keys() {
570        if let Some(text) = env.source_text(key) {
571            driver.db_mut().set_file(key, text.into_owned());
572        }
573    }
574
575    if driver.db_mut().set_entry(&env.entry).is_none() {
576        return Err(CompileError::Io(io::Error::new(
577            io::ErrorKind::NotFound,
578            format!("entry file not in environment: {}", env.entry),
579        )));
580    }
581
582    let product = driver.db().story_data().cloned().unwrap_or_default();
583
584    let Some(story) = product.story else {
585        let mut all = product.errors;
586        all.extend(product.warnings);
587        return Err(CompileError::Diagnostics(resolve_diagnostics(
588            driver.db(),
589            all,
590        )));
591    };
592
593    Ok(CompileOutput {
594        data: Arc::unwrap_or_clone(story),
595        warnings: resolve_diagnostics(driver.db(), product.warnings),
596    })
597}
598
599/// Resolve `FileId`-keyed diagnostics to path-carrying [`ResolvedDiagnostic`]s
600/// while the db is still alive (it owns the `FileId`→path map). Mirrors
601/// `brink-compiler`'s own resolution, using only the public `ProjectDb` API —
602/// including resolving `severity` through `brink_driver::effective_severity`
603/// against the db's own `AnalysisOptions`, same as the mirrored function
604/// (issue #1162), so the two never drift on which severity a diagnostic
605/// carries.
606fn resolve_diagnostics(
607    db: &brink_driver::ProjectDb,
608    diags: Vec<Diagnostic>,
609) -> Vec<ResolvedDiagnostic> {
610    let opts = db.analysis_options();
611    let types = opts.type_policy();
612    diags
613        .into_iter()
614        // Suppressed by `[lints] allow` — dropped rather than resolved
615        // (#3173).
616        .filter_map(|d| {
617            let severity = brink_driver::effective_severity(d.code, types, &opts.lints)?;
618            Some(ResolvedDiagnostic {
619                path: db.file_path(d.file).unwrap_or_default().to_string(),
620                file: d.file,
621                range: d.range,
622                message: d.message,
623                severity,
624                code: d.code,
625            })
626        })
627        .collect()
628}
629
630// ── Errors ───────────────────────────────────────────────────────────
631
632/// A failure producing an [`Environment`] from a mount.
633#[derive(Debug, thiserror::Error)]
634pub enum LoadError {
635    /// An I/O error reading or enumerating the tree.
636    #[error("I/O error: {0}")]
637    Io(#[from] io::Error),
638    /// `INCLUDE` discovery failed (missing include, circular include).
639    #[error("discovery error: {0}")]
640    Discover(#[from] brink_driver::DiscoverError),
641    /// A discovered `brink.toml` could not be parsed (malformed TOML, or a
642    /// recognized key with an out-of-range value). Unknown keys are warnings,
643    /// never this error. Carries the discovered `path` so the message names
644    /// which file failed — lost when this variant went through a bare
645    /// `#[from] ConfigError` in #1306 (#1369 restores it). `source` is
646    /// itself now path-carrying too (#1384: `parse_str_at` threads `path`
647    /// into every `ConfigError` it raises), so this field is kept for
648    /// structural/pattern-matching access (existing callers destructure
649    /// `LoadError::Config { path, .. }`) rather than duplicated into the
650    /// rendered message — `source`'s own `Display` already names the file.
651    /// `source` is boxed: `ConfigError` grew past `clippy::result_large_err`'s
652    /// threshold once #1384 threaded `path` into its own variants, and this
653    /// is the one `LoadError` variant that stacks a second `path` field on
654    /// top of it.
655    #[error("{source}")]
656    Config {
657        /// The root-relative key of the `brink.toml` that failed to parse.
658        path: String,
659        #[source]
660        source: Box<ConfigError>,
661    },
662    /// A discovered `brink.toml` could not be *read* (permission error,
663    /// non-UTF-8 bytes, or any other I/O failure) — the other half of
664    /// #1369's regression: `tree.read(&config_key)?` used to fall through
665    /// the bare `#[from] io::Error` on [`LoadError::Io`], which carries no
666    /// path (`RealFs::read` is `fs::read_to_string`, and std I/O errors
667    /// don't carry the path that failed). Carries the discovered `path` so
668    /// this half names the file too.
669    #[error("failed to read project config {path}: {source}")]
670    ConfigRead {
671        /// The root-relative key of the `brink.toml` that failed to read.
672        path: String,
673        #[source]
674        source: io::Error,
675    },
676    /// A native source key is not root-relative (contains a `..` segment) — a
677    /// save-key-identity guardrail against a `SourceTree` that violates the
678    /// contract.
679    #[error("invalid source key `{0}` (must be root-relative, no `..`)")]
680    InvalidSourceKey(String),
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use brink_driver::LintLevel;
687    use brink_source_tree::InMemory;
688
689    fn tree(files: &[(&str, &str)]) -> InMemory {
690        InMemory::new(
691            files
692                .iter()
693                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
694                .collect::<BTreeMap<_, _>>(),
695        )
696    }
697
698    // ── content addressing ───────────────────────────────────────────
699
700    #[test]
701    fn content_hash_is_deterministic_and_content_addressed() {
702        assert_eq!(ContentHash::of("hello"), ContentHash::of("hello"));
703        assert_ne!(ContentHash::of("hello"), ContentHash::of("world"));
704    }
705
706    #[test]
707    fn source_text_resolves_through_key_hash_store() {
708        let t = tree(&[("main.brink", "flow main() {}")]);
709        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
710
711        assert_eq!(
712            env.source_text("main.brink").as_deref(),
713            Some("flow main() {}")
714        );
715        assert_eq!(env.source_text("absent.brink"), None);
716    }
717
718    #[test]
719    fn identical_content_is_stored_once_but_keyed_twice() {
720        let t = tree(&[("a.brink", "flow a() {}"), ("b.brink", "flow a() {}")]);
721        let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
722
723        let ContentStore::Inline(store) = &env.content;
724        // Two project manifest keys (deduplicated to one stored blob) plus
725        // the mounted stdlib entry (#2080) — three keys, two stored blobs.
726        assert_eq!(env.source_keys().count(), 3);
727        assert_eq!(store.len(), 2);
728    }
729
730    #[test]
731    fn source_keys_are_sorted() {
732        let t = tree(&[
733            ("z.brink", "flow z() {}"),
734            ("a.brink", "flow a() {}"),
735            ("m.brink", "flow m() {}"),
736        ]);
737        let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
738        let keys: Vec<_> = env.source_keys().collect();
739        // The mounted stdlib key (#2080) sorts between "m.brink" and
740        // "z.brink".
741        assert_eq!(
742            keys,
743            vec![
744                "a.brink",
745                "m.brink",
746                "std/conventions/screenplay.brink",
747                "z.brink"
748            ]
749        );
750    }
751
752    // ── serialize / round-trip / hash ────────────────────────────────
753
754    #[test]
755    fn environment_round_trips_through_json_unchanged() {
756        let t = tree(&[("main.brink", "flow main() {}")]);
757        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
758
759        let json = serde_json::to_string(&env).expect("serializes");
760        let back: Environment = serde_json::from_str(&json).expect("deserializes");
761
762        assert_eq!(env, back);
763        assert_eq!(env.content_hash(), back.content_hash());
764    }
765
766    #[test]
767    fn content_hash_changes_when_a_source_changes() {
768        let a = Project::load(
769            &tree(&[("m.brink", "flow m() {}")]),
770            "m.brink",
771            &OptionOverrides::default(),
772        )
773        .expect("loads");
774        let b = Project::load(
775            &tree(&[("m.brink", "flow m() { Hi. }")]),
776            "m.brink",
777            &OptionOverrides::default(),
778        )
779        .expect("loads");
780        assert_ne!(a.content_hash(), b.content_hash());
781    }
782
783    #[test]
784    fn content_hash_changes_when_options_change() {
785        let base = tree(&[("m.brink", "flow m() {}")]);
786        let default = Project::load(&base, "m.brink", &OptionOverrides::default()).expect("loads");
787        let overridden = Project::load(
788            &base,
789            "m.brink",
790            &OptionOverrides {
791                dialect: Some(Dialect::Brink),
792                ..OptionOverrides::default()
793            },
794        )
795        .expect("loads");
796        assert_ne!(default.content_hash(), overridden.content_hash());
797    }
798
799    // ── config resolution / override precedence ──────────────────────
800
801    #[test]
802    fn brink_toml_dialect_is_discovered_over_the_tree() {
803        let t = tree(&[
804            ("brink.toml", "[project]\ndialect = \"brink\"\n"),
805            ("main.brink", "flow main() {}"),
806        ]);
807        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
808        assert_eq!(env.options.dialect, Dialect::Brink);
809    }
810
811    #[test]
812    fn brink_toml_is_discovered_by_walking_up_from_the_entry() {
813        let t = tree(&[
814            ("brink.toml", "[project]\ndialect = \"brink\"\n"),
815            ("chapters/main.brink", "flow main() {}"),
816        ]);
817        let env =
818            Project::load(&t, "chapters/main.brink", &OptionOverrides::default()).expect("loads");
819        assert_eq!(env.options.dialect, Dialect::Brink);
820    }
821
822    #[test]
823    fn explicit_override_wins_over_brink_toml() {
824        let t = tree(&[
825            ("brink.toml", "[project]\ndialect = \"brink\"\n"),
826            ("main.brink", "flow main() {}"),
827        ]);
828        let env = Project::load(
829            &t,
830            "main.brink",
831            &OptionOverrides {
832                dialect: Some(Dialect::StrictInk),
833                ..OptionOverrides::default()
834            },
835        )
836        .expect("loads");
837        assert_eq!(env.options.dialect, Dialect::StrictInk);
838    }
839
840    #[test]
841    fn no_brink_toml_yields_default_options() {
842        let t = tree(&[("main.brink", "flow main() {}")]);
843        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
844        assert_eq!(env.options, AnalysisOptions::default());
845    }
846
847    /// #1436: pins the "repeat compiles are deterministic" invariant
848    /// `Project::load`'s doc comment now names explicitly —
849    /// `resolve_options` must resolve every call from a fresh
850    /// `AnalysisOptions::default()`, never one mutated by a prior call.
851    ///
852    /// First compile resolves `dialect = Brink` from its own `brink.toml`.
853    /// The second, completely unrelated compile has no `brink.toml` at
854    /// all — if `resolve_options` ever stopped constructing `default()`
855    /// fresh (e.g. a future change hoisted/cached `AnalysisOptions` across
856    /// `load` calls "for efficiency"), `apply_project_config`'s "unset
857    /// means untouched" rule for `dialect`/`types` would let the first
858    /// call's `Brink` silently survive into the second call's result
859    /// instead of resolving to the dialect-less default — exactly the
860    /// leak `AnalysisOptions::apply_project_config`'s own "must be fresh"
861    /// doc warns every non-editor-session caller against.
862    #[test]
863    fn repeat_compiles_do_not_leak_options_across_project_load_calls() {
864        let brink_tree = tree(&[
865            ("brink.toml", "[project]\ndialect = \"brink\"\n"),
866            ("main.brink", "flow main() {}"),
867        ]);
868        let first =
869            Project::load(&brink_tree, "main.brink", &OptionOverrides::default()).expect("loads");
870        assert_eq!(first.options.dialect, Dialect::Brink);
871
872        let default_tree = tree(&[("main2.brink", "flow main() {}")]);
873        let second = Project::load(&default_tree, "main2.brink", &OptionOverrides::default())
874            .expect("loads");
875        assert_eq!(
876            second.options,
877            AnalysisOptions::default(),
878            "a later, unrelated Project::load call must never observe an \
879             earlier call's resolved AnalysisOptions -- each call must \
880             resolve from a fresh AnalysisOptions::default(), not a \
881             reused/mutated one; got {:?}",
882            second.options
883        );
884    }
885
886    #[test]
887    fn malformed_brink_toml_is_a_load_error() {
888        let t = tree(&[
889            ("brink.toml", "[project]\ndialect = \"sideways\"\n"),
890            ("main.brink", "flow main() {}"),
891        ]);
892        let err = Project::load(&t, "main.brink", &OptionOverrides::default())
893            .expect_err("invalid dialect value must fail load");
894        assert!(matches!(err, LoadError::Config { .. }));
895    }
896
897    /// #1369: the `Config` error must name the discovered `brink.toml`'s
898    /// path — lost since #1306 when the variant became a bare
899    /// `#[from] ConfigError` with no path carried alongside it.
900    #[test]
901    fn malformed_brink_toml_error_names_its_path() {
902        let t = tree(&[
903            ("brink.toml", "[project]\ndialect = \"sideways\"\n"),
904            ("main.brink", "flow main() {}"),
905        ]);
906        let err = Project::load(&t, "main.brink", &OptionOverrides::default())
907            .expect_err("invalid dialect value must fail load");
908        let LoadError::Config { path, .. } = &err else {
909            unreachable!("expected LoadError::Config, got {err:?}");
910        };
911        assert_eq!(path, "brink.toml");
912        assert!(
913            err.to_string().contains("brink.toml"),
914            "error message must name the malformed file, got: {err}"
915        );
916    }
917
918    /// Nested discovery (walking up from a subdirectory) must report the
919    /// `brink.toml`'s actual discovered *key* — a multi-segment root-relative
920    /// path — not just a bare filename. A `brink.toml` at the tree root
921    /// (the previous fixture) discovers as the bare `"brink.toml"` key
922    /// itself, which the preceding test already proves; this fixture nests
923    /// the `brink.toml` too, so `path` only passes if the discovered key is
924    /// actually threaded through rather than, say, a hardcoded filename.
925    #[test]
926    fn malformed_brink_toml_error_names_its_nested_path() {
927        let t = tree(&[
928            ("chapters/brink.toml", "[project]\ndialect = \"sideways\"\n"),
929            ("chapters/deep/main.brink", "flow main() {}"),
930        ]);
931        let err = Project::load(&t, "chapters/deep/main.brink", &OptionOverrides::default())
932            .expect_err("invalid dialect value must fail load");
933        let LoadError::Config { path, .. } = &err else {
934            unreachable!("expected LoadError::Config, got {err:?}");
935        };
936        assert_eq!(path, "chapters/brink.toml");
937        assert!(
938            err.to_string().contains("chapters/brink.toml"),
939            "error message must name the nested malformed file, got: {err}"
940        );
941    }
942
943    /// #1369's other half: a `brink.toml` that is *discovered* but fails to
944    /// *read* (non-UTF-8 bytes) must also name its path — the failure mode
945    /// that fell through the bare `#[from] io::Error` on `LoadError::Io`
946    /// pre-fix, since `RealFs::read` (`fs::read_to_string`) returns a std
947    /// I/O error with no path attached. `InMemory` can't represent invalid
948    /// UTF-8 (its map is `String`-keyed and -valued), so this exercises the
949    /// real filesystem via `RealFs` instead.
950    #[test]
951    fn unreadable_brink_toml_error_names_its_path() {
952        let dir = std::env::temp_dir().join(format!(
953            "brink-environment-unreadable-config-{}",
954            std::process::id()
955        ));
956        std::fs::create_dir_all(&dir).unwrap();
957        // Invalid UTF-8: a lone continuation byte can never start a valid
958        // UTF-8 sequence, so `fs::read_to_string` fails with `InvalidData`.
959        std::fs::write(dir.join("brink.toml"), [0x80_u8, 0x81, 0x82]).unwrap();
960        std::fs::write(dir.join("main.brink"), "flow main() {}").unwrap();
961
962        let t = brink_driver::RealFs::new(&dir);
963        let err = Project::load(&t, "main.brink", &OptionOverrides::default())
964            .expect_err("non-UTF-8 brink.toml must fail load");
965        let LoadError::ConfigRead { path, .. } = &err else {
966            unreachable!("expected LoadError::ConfigRead, got {err:?}");
967        };
968        assert_eq!(path, "brink.toml");
969        assert!(
970            err.to_string().contains("brink.toml"),
971            "error message must name the unreadable file, got: {err}"
972        );
973
974        std::fs::remove_dir_all(&dir).ok();
975    }
976
977    // ── [lints] resolution (issue #1160) ──────────────────────────────
978    //
979    // `Project::load` is the ONE point that folds a discovered `brink.toml`
980    // into the resolved `AnalysisOptions` (via
981    // `AnalysisOptions::apply_project_config`) — these tests exercise that
982    // exact seam, then prove the resolved policy is actually consulted by
983    // `compile`'s error gate (not just stored inertly on `Environment`).
984
985    /// A logic line with no effect (`~` alone) — `DiagnosticCode::E014`,
986    /// `Warning` by default (`brink_ir::hir::lower::tests::
987    /// logic_line_emits_diagnostic_on_malformed`).
988    const E014_SOURCE: &str = "Hello.\n~\n-> END\n";
989
990    #[test]
991    fn brink_toml_lints_table_resolves_into_options() {
992        let t = tree(&[
993            ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
994            ("main.ink", E014_SOURCE),
995        ]);
996        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
997        assert_eq!(
998            env.options.lints.overrides.get("E014"),
999            Some(&LintLevel::Deny)
1000        );
1001    }
1002
1003    #[test]
1004    fn brink_toml_deny_warnings_resolves_into_options() {
1005        let t = tree(&[
1006            ("brink.toml", "[lints]\ndeny-warnings = true\n"),
1007            ("main.ink", E014_SOURCE),
1008        ]);
1009        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1010        assert!(env.options.lints.deny_warnings);
1011    }
1012
1013    #[test]
1014    fn absent_lints_table_leaves_options_lints_at_default() {
1015        let t = tree(&[("main.ink", E014_SOURCE)]);
1016        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1017        assert_eq!(env.options.lints, brink_driver::LintPolicy::default());
1018    }
1019
1020    #[test]
1021    fn e014_warning_compiles_cleanly_by_default() {
1022        // No `[lints]` table: E014 stays a Warning, never blocks compile —
1023        // "absent table = today's behavior" acceptance criterion.
1024        let t = tree(&[("main.ink", E014_SOURCE)]);
1025        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1026        let out = compile(&env).expect("a Warning-only diagnostic must not block compilation");
1027        assert!(
1028            out.warnings
1029                .iter()
1030                .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1031            "expected E014 among the warnings: {:?}",
1032            out.warnings
1033        );
1034    }
1035
1036    #[test]
1037    fn brink_toml_lints_deny_relevels_e014_and_blocks_compile() {
1038        // The same source as above, but `[lints] E014 = "deny"` re-levels
1039        // it to Error — this must now fail the same `has_errors`-style
1040        // gate `compile` reads through `Environment.options`.
1041        let t = tree(&[
1042            ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
1043            ("main.ink", E014_SOURCE),
1044        ]);
1045        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1046        let err = compile(&env).expect_err("a denied E014 must block compilation");
1047        let CompileError::Diagnostics(diags) = err else {
1048            unreachable!("expected CompileError::Diagnostics, got {err:?}");
1049        };
1050        assert!(
1051            diags
1052                .iter()
1053                .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1054            "expected E014 among the surfaced diagnostics: {diags:?}"
1055        );
1056    }
1057
1058    #[test]
1059    fn brink_toml_deny_warnings_blocks_compile_on_an_unconfigured_warning() {
1060        // `deny-warnings = true` with no per-code override: E014 (an
1061        // ordinary, unconfigured Warning) is still promoted to Error.
1062        let t = tree(&[
1063            ("brink.toml", "[lints]\ndeny-warnings = true\n"),
1064            ("main.ink", E014_SOURCE),
1065        ]);
1066        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1067        let err = compile(&env).expect_err("deny-warnings must promote E014 to a compile error");
1068        assert!(matches!(err, CompileError::Diagnostics(_)));
1069    }
1070
1071    // ── OptionOverrides.lints / .deny_warnings: CLI/API tier (#1373) ──
1072
1073    #[test]
1074    fn override_lints_resolves_into_options() {
1075        let t = tree(&[("main.ink", E014_SOURCE)]);
1076        let mut lints = BTreeMap::new();
1077        lints.insert("E014".to_owned(), LintLevel::Deny);
1078        let env = Project::load(
1079            &t,
1080            "main.ink",
1081            &OptionOverrides {
1082                lints,
1083                ..OptionOverrides::default()
1084            },
1085        )
1086        .expect("loads");
1087        assert_eq!(
1088            env.options.lints.overrides.get("E014"),
1089            Some(&LintLevel::Deny)
1090        );
1091    }
1092
1093    #[test]
1094    fn override_deny_e014_blocks_compile_with_no_brink_toml() {
1095        // No `brink.toml` at all — a CLI `--deny E014` alone must still
1096        // relevel E014 to Error and block compilation, exactly as a file's
1097        // `[lints] E014 = "deny"` already does.
1098        let t = tree(&[("main.ink", E014_SOURCE)]);
1099        let mut lints = BTreeMap::new();
1100        lints.insert("E014".to_owned(), LintLevel::Deny);
1101        let env = Project::load(
1102            &t,
1103            "main.ink",
1104            &OptionOverrides {
1105                lints,
1106                ..OptionOverrides::default()
1107            },
1108        )
1109        .expect("loads");
1110        let err = compile(&env).expect_err("CLI --deny E014 must block compilation");
1111        let CompileError::Diagnostics(diags) = err else {
1112            unreachable!("expected CompileError::Diagnostics, got {err:?}");
1113        };
1114        assert!(
1115            diags
1116                .iter()
1117                .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1118            "expected E014 among the surfaced diagnostics: {diags:?}"
1119        );
1120    }
1121
1122    #[test]
1123    fn override_deny_warnings_blocks_compile_with_no_brink_toml() {
1124        let t = tree(&[("main.ink", E014_SOURCE)]);
1125        let env = Project::load(
1126            &t,
1127            "main.ink",
1128            &OptionOverrides {
1129                deny_warnings: Some(true),
1130                ..OptionOverrides::default()
1131            },
1132        )
1133        .expect("loads");
1134        let err = compile(&env).expect_err("CLI -D warnings must promote E014 to a compile error");
1135        assert!(matches!(err, CompileError::Diagnostics(_)));
1136    }
1137
1138    #[test]
1139    fn override_lints_wins_over_a_conflicting_brink_toml_entry() {
1140        // `brink.toml` denies E014; the CLI override allows it — the CLI
1141        // must win (#1005/#1373's `CLI/API > file > default` precedence),
1142        // so the same source now compiles cleanly.
1143        let t = tree(&[
1144            ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
1145            ("main.ink", E014_SOURCE),
1146        ]);
1147        let mut lints = BTreeMap::new();
1148        lints.insert("E014".to_owned(), LintLevel::Allow);
1149        let env = Project::load(
1150            &t,
1151            "main.ink",
1152            &OptionOverrides {
1153                lints,
1154                ..OptionOverrides::default()
1155            },
1156        )
1157        .expect("loads");
1158        assert_eq!(
1159            env.options.lints.overrides.get("E014"),
1160            Some(&LintLevel::Allow),
1161            "the CLI override must replace the file's E014 = deny"
1162        );
1163        compile(&env).expect("CLI --allow E014 must win over brink.toml's E014 = deny");
1164    }
1165
1166    // ── native universe = whole tree; brink.toml never a source ──────
1167
1168    #[test]
1169    fn native_universe_is_the_whole_tree_excluding_config() {
1170        let t = tree(&[
1171            ("brink.toml", "[project]\n"),
1172            ("main.brink", "flow main() {}"),
1173            ("lib/util.brink", "flow util() {}"),
1174            ("README.md", "not source"),
1175        ]);
1176        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1177        let keys: Vec<_> = env.source_keys().collect();
1178        // The mounted stdlib key (#2080) sorts between "main.brink" and
1179        // nothing else here, since 'm' < 's'.
1180        assert_eq!(
1181            keys,
1182            vec![
1183                "lib/util.brink",
1184                "main.brink",
1185                "std/conventions/screenplay.brink"
1186            ]
1187        );
1188    }
1189
1190    #[test]
1191    fn dotdot_native_key_is_rejected() {
1192        struct Hostile;
1193        impl SourceTree for Hostile {
1194            fn list(&self) -> io::Result<Vec<String>> {
1195                Ok(vec!["a.brink".to_string(), "../escape.brink".to_string()])
1196            }
1197            fn read(&self, key: &str) -> io::Result<String> {
1198                Ok(format!("-- {key} --"))
1199            }
1200        }
1201        let err = Project::load(&Hostile, "a.brink", &OptionOverrides::default())
1202            .expect_err("dotdot key must be rejected");
1203        assert!(matches!(err, LoadError::InvalidSourceKey(k) if k == "../escape.brink"));
1204    }
1205
1206    // ── ink INCLUDE discovery ────────────────────────────────────────
1207
1208    #[test]
1209    fn ink_universe_follows_the_include_graph() {
1210        let t = tree(&[
1211            ("main.ink", "INCLUDE lib.ink\nHello.\n-> END\n"),
1212            ("lib.ink", "== helper ==\n-> DONE\n"),
1213            ("unreferenced.ink", "== orphan ==\n-> DONE\n"),
1214        ]);
1215        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1216        let keys: Vec<_> = env.source_keys().collect();
1217        // The orphan is not INCLUDE-reachable, so it is not in the universe.
1218        // The stdlib mount (#2080) is unconditional — it joins an ink
1219        // project's environment too, sorting after "main.ink".
1220        assert_eq!(
1221            keys,
1222            vec!["lib.ink", "main.ink", "std/conventions/screenplay.brink"]
1223        );
1224    }
1225
1226    // ── stdlib mount (#2080) ──────────────────────────────────────────
1227
1228    #[test]
1229    fn stdlib_screenplay_preset_is_mounted_into_every_environment() {
1230        let t = tree(&[("main.brink", "flow main() {}")]);
1231        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1232
1233        let mounted = env
1234            .source_text("std/conventions/screenplay.brink")
1235            .expect("the built-in screenplay preset must be mounted into every Environment");
1236        assert!(
1237            mounted.contains("heading"),
1238            "mounted stdlib text looks wrong (embed path misconfigured?): {mounted}"
1239        );
1240    }
1241
1242    /// Reverting `mount_stdlib` (or not calling it from `Project::load`)
1243    /// makes this fail: `source_text` for the stdlib key returns `None`.
1244    #[test]
1245    fn stdlib_mount_is_present_for_a_native_and_an_ink_entry_alike() {
1246        let native = Project::load(
1247            &tree(&[("main.brink", "flow main() {}")]),
1248            "main.brink",
1249            &OptionOverrides::default(),
1250        )
1251        .expect("loads");
1252        let ink = Project::load(
1253            &tree(&[("main.ink", "Hello.\n-> END\n")]),
1254            "main.ink",
1255            &OptionOverrides::default(),
1256        )
1257        .expect("loads");
1258
1259        assert!(
1260            native
1261                .source_text("std/conventions/screenplay.brink")
1262                .is_some()
1263        );
1264        assert!(
1265            ink.source_text("std/conventions/screenplay.brink")
1266                .is_some()
1267        );
1268    }
1269
1270    #[test]
1271    fn a_project_source_at_the_stdlib_key_wins_over_the_embedded_copy() {
1272        let t = tree(&[
1273            ("main.brink", "flow main() {}"),
1274            (
1275                "std/conventions/screenplay.brink",
1276                "// project-authored override\nflow overridden() {}",
1277            ),
1278        ]);
1279        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1280        assert_eq!(
1281            env.source_text("std/conventions/screenplay.brink")
1282                .as_deref(),
1283            Some("// project-authored override\nflow overridden() {}"),
1284            "a project's own file at the stdlib's key must win, not be silently clobbered \
1285             by the embedded copy"
1286        );
1287    }
1288
1289    #[test]
1290    fn mounted_stdlib_compiles_cleanly_alongside_an_ordinary_native_project() {
1291        // Proves the mount reaches the real, sole production compile path
1292        // (`brink_environment::compile`, per #2080's ruling) — not just
1293        // that the manifest holds the text. A plain native project must
1294        // still compile, and the mounted stdlib module must not itself
1295        // introduce any diagnostic.
1296        //
1297        // `warnings.is_empty()` alone is vacuous here (review finding on
1298        // #2080): a bare `main.brink` project compiles with zero warnings
1299        // whether or not `mount_stdlib` ran at all, so it cannot
1300        // distinguish "the mount reached the compile" from "the mount
1301        // didn't happen". A native entry's compilation universe is
1302        // "tree is universe" (every `.brink` key joins), so the mounted
1303        // screenplay preset's `heading` handler — which declares
1304        // `extern scene_entered(title, slug)` — must actually land in the
1305        // compiled `StoryData`'s externals table. Assert that too.
1306        let t = tree(&[("main.brink", "flow main() { Hello. }")]);
1307        let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1308        let out = compile(&env).expect(
1309            "a plain native project must compile cleanly with the stdlib mounted alongside it",
1310        );
1311        assert!(
1312            out.warnings.is_empty(),
1313            "the mounted stdlib module must not itself introduce diagnostics: {:?}",
1314            out.warnings
1315        );
1316        let has_scene_entered_extern = out.data.externals.iter().any(|ext| {
1317            out.data
1318                .name_table
1319                .get(ext.name.0 as usize)
1320                .is_some_and(|name| name == "scene_entered")
1321        });
1322        assert!(
1323            has_scene_entered_extern,
1324            "the mounted screenplay preset's `heading` handler declares \
1325             `extern scene_entered(title, slug)` — its absence from the \
1326             compiled externs means the mount never reached the compile at \
1327             all, got externs: {:?}",
1328            out.data.externals
1329        );
1330    }
1331
1332    #[test]
1333    fn stdlib_mount_is_manifest_only_for_an_ink_entry() {
1334        // Distinguishes native-entry reachability (asserted just above)
1335        // from ink-entry reachability, which review found is NOT the
1336        // same: `brink-db`'s `compilation_closure_files` walks an ink
1337        // entry's `INCLUDE` graph (`topological_order`), and the mounted
1338        // `.brink` key has no `INCLUDE` edge into it, so it is excluded
1339        // from an ink compile's closure entirely — present in the
1340        // `Environment`'s manifest, never lowered into LIR, contributing
1341        // nothing to the compiled story. The PR's original changeset and
1342        // reachability prose claimed the mount is "compiled as an
1343        // ordinary native module alongside the project's own files" /
1344        // "participates in native module resolution exactly like any
1345        // project file" for every compile — true for a native entry
1346        // (previous test), false for an ink one (this test), which is
1347        // `@brink-lang/web`'s ordinary case.
1348        let t = tree(&[("main.ink", "Hello.\n-> END\n")]);
1349        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1350        let out = compile(&env).expect(
1351            "a plain ink project must compile cleanly with the stdlib mounted alongside it",
1352        );
1353        assert!(
1354            out.warnings.is_empty(),
1355            "the mounted stdlib module must not itself introduce diagnostics \
1356             for an ink entry either: {:?}",
1357            out.warnings
1358        );
1359        let has_scene_entered_extern = out.data.externals.iter().any(|ext| {
1360            out.data
1361                .name_table
1362                .get(ext.name.0 as usize)
1363                .is_some_and(|name| name == "scene_entered")
1364        });
1365        assert!(
1366            !has_scene_entered_extern,
1367            "an ink entry's compilation closure must NOT include the \
1368             mounted, manifest-only stdlib module (no INCLUDE edge reaches \
1369             it) — its presence here would mean the mount reaches ink \
1370             compiles too, contradicting the manifest-only scope fence: \
1371             {:?}",
1372            out.data.externals
1373        );
1374    }
1375
1376    #[test]
1377    fn mounted_stdlib_introduces_no_diagnostics_under_types_strict() {
1378        // Review finding on #2080: the mounted module now sits inside
1379        // every native project's compilation closure, but the only
1380        // existing compile test for it (`mounted_stdlib_compiles_cleanly_
1381        // alongside_an_ordinary_native_project`, above) runs under
1382        // `AnalysisOptions::default()`, which resolves `TypePolicy::
1383        // Gradual`. A real `.brink` project setting `dialect = brink` in
1384        // `brink.toml` resolves `TypePolicy::Strict` instead
1385        // (`tier1_native_strict.rs`'s own module doc), and any strict
1386        // diagnostic the mounted module produced would then fail every
1387        // strict build. It is clean today — `tier1_native_strict.rs`'s
1388        // baseline has zero rows for `conventions-screenplay-preset` —
1389        // which is exactly what makes this guard cheap to add now, before
1390        // the module grows and a strict finding sneaks in unnoticed.
1391        let t = tree(&[("main.brink", "flow main() { Hello. }")]);
1392        let overrides = OptionOverrides {
1393            types: Some(TypePolicy::Strict),
1394            ..OptionOverrides::default()
1395        };
1396        let env = Project::load(&t, "main.brink", &overrides).expect("loads");
1397        let out = compile(&env).expect(
1398            "a plain native project must compile cleanly under types = strict \
1399             with the stdlib mounted alongside it",
1400        );
1401        assert!(
1402            out.warnings.is_empty(),
1403            "the mounted stdlib module must not itself introduce diagnostics \
1404             under types = strict: {:?}",
1405            out.warnings
1406        );
1407    }
1408
1409    // ── issue #2240 review finding: E181 reachability ─────────────────
1410
1411    /// The `E181` reachability claim this PR originally shipped ("not
1412    /// reachable from any project compilable today") is false. A plain
1413    /// `Brink`-dialect ink project (dialect must be `Brink` for `STRUCT` to
1414    /// parse at all — under the default `StrictInk` it is `E051`, see
1415    /// `issue_460_shared_chunk_ctx.rs`'s own `brink_opts` doc) that declares
1416    /// its own `struct Cue` collides with the std-mounted `screenplay`
1417    /// preset's own `Cue` (#2080): `symbol_index_query` builds the shared
1418    /// index from every `set_file`-registered file regardless of the
1419    /// compilation closure (`compile`'s own loop registers the mounted
1420    /// stdlib key alongside the project's, per
1421    /// `stdlib_mount_is_manifest_only_for_an_ink_entry`'s own doc just
1422    /// above), so std's `Cue` sits in the index even though it never
1423    /// itself joins an ink entry's LIR closure. Neither struct declares a
1424    /// `#@module`, and this project is not all-native
1425    /// (`project_is_all_native`), so M-2d cross-declared-module coexistence
1426    /// never even applies (`manifest::is_cross_declared_module_collision`) —
1427    /// `insert_symbol` treats the pair as a true intra-module duplicate
1428    /// (`E023`).
1429    ///
1430    /// The entry is deliberately named `story.ink`, not `main.ink`:
1431    /// `FileId` mint order follows `set_file` call order, which follows
1432    /// `Environment::source_keys`'s sorted (`BTreeMap`) manifest order —
1433    /// `"story.ink"` sorts *after* `"std/conventions/screenplay.brink"`
1434    /// lexicographically (`'d' < 'o'` at the third byte), so std's `Cue`
1435    /// is inserted into the index first and it is the PROJECT's own later
1436    /// `Cue` that `insert_symbol` drops — the direction the review finding
1437    /// traced. (`main.ink` sorts *before* `"std/…"` and drops std's `Cue`
1438    /// instead, which raises no diagnostic at all since nothing else
1439    /// references it — verified while building this test.)
1440    ///
1441    /// Once the project's own `Cue` is dropped, `build_shape_table`'s
1442    /// self-lookup for it misses on both its exact-file arm (no entry left
1443    /// in `story.ink`) and the unscoped fallback (std's surviving `Cue` is
1444    /// excluded by the fallback's own std-visibility carve-out, issue
1445    /// #2197) — exactly the condition `E181`'s doc describes, and it fires
1446    /// here through the REAL analyzer drop, not a hand-built `SymbolIndex`.
1447    ///
1448    /// Per rule 20a: reverting the `E181` push in
1449    /// `structs::build_shape_table` (restoring the bare `continue`) makes
1450    /// this test fail — `compile` would then succeed instead of erroring,
1451    /// since the struct would go back to being silently dropped with no
1452    /// diagnostic at all.
1453    #[test]
1454    fn e181_is_reachable_from_an_ordinary_ink_project_colliding_with_a_std_preset_name() {
1455        let t = tree(&[(
1456            "story.ink",
1457            "STRUCT Cue = #{\n  speaker: string,\n}\nHello.\n-> END\n",
1458        )]);
1459        let overrides = OptionOverrides {
1460            dialect: Some(Dialect::Brink),
1461            ..OptionOverrides::default()
1462        };
1463        let env = Project::load(&t, "story.ink", &overrides).expect("loads");
1464
1465        let err = compile(&env).expect_err(
1466            "a project struct colliding with a std-declared homonym must raise \
1467             E181 and fail compilation, not silently drop the struct",
1468        );
1469        let CompileError::Diagnostics(diags) = err else {
1470            unreachable!("expected a Diagnostics compile error, got: {err:?}");
1471        };
1472        assert!(
1473            diags
1474                .iter()
1475                .any(|d| d.code == brink_ir::DiagnosticCode::E181),
1476            "expected E181 among the blocking diagnostics: {diags:?}"
1477        );
1478    }
1479
1480    // ── issue #2262: the same silent-drop class recurs for `EXTERNAL` ──
1481
1482    /// `E181`'s reachability fix (above) covers `STRUCT` only —
1483    /// `lir::lower::decls::collect_externals`' own self-declaration lookup
1484    /// (`lookup_global(index, file_id, &ext.name.text,
1485    /// SymbolKind::External)`) has the textually identical no-`else`
1486    /// pattern and is reached the exact same way: an ordinary ink project
1487    /// (no `dialect` override needed — `EXTERNAL` is core ink, unlike
1488    /// `STRUCT`) that declares its own `EXTERNAL scene_entered(...)`
1489    /// collides with the std-mounted screenplay preset's own `extern
1490    /// scene_entered` (`std/conventions/screenplay.brink`). Neither
1491    /// declares a `#@module` and this project is not all-native, so M-2d
1492    /// cross-declared-module coexistence never applies — `insert_symbol`
1493    /// treats the pair as a true intra-module duplicate (`E023`) and drops
1494    /// the later one. `"story.ink"` sorts after `"std/conventions/
1495    /// screenplay.brink"` (same ordering argument as the `E181` test), so
1496    /// it is the *project's* `scene_entered` that is dropped.
1497    ///
1498    /// Before issue #2262's fix: this compiles CLEAN (`compile(&env)` is
1499    /// `Ok`) with the project's own `EXTERNAL scene_entered` silently
1500    /// absent from `out.data.externals` — no diagnostic at all, unlike
1501    /// `E181`'s struct case. That is the bug this test proves and pins the
1502    /// fix against.
1503    #[test]
1504    fn external_self_declaration_silently_drops_when_colliding_with_a_std_preset_name() {
1505        let t = tree(&[(
1506            "story.ink",
1507            "EXTERNAL scene_entered(title, slug)\nHello.\n-> END\n",
1508        )]);
1509        let env = Project::load(&t, "story.ink", &OptionOverrides::default()).expect("loads");
1510
1511        let err = compile(&env).expect_err(
1512            "a project EXTERNAL colliding with a std-declared homonym must raise \
1513             a diagnostic and fail compilation, not silently drop the external",
1514        );
1515        let CompileError::Diagnostics(diags) = err else {
1516            unreachable!("expected a Diagnostics compile error, got: {err:?}");
1517        };
1518        assert!(
1519            diags
1520                .iter()
1521                .any(|d| d.code == brink_ir::DiagnosticCode::E184),
1522            "expected E184 (the EXTERNAL/CONST/VAR twin of E181) among the \
1523             blocking diagnostics: {diags:?}"
1524        );
1525    }
1526
1527    // ── the pure compile over the input ──────────────────────────────
1528
1529    #[test]
1530    fn compile_over_environment_produces_story_data() {
1531        let t = tree(&[("main.ink", "Hello, world.\n-> END\n")]);
1532        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1533        let out = compile(&env).expect("compiles");
1534        // A real story compiled: at least one container of instructions.
1535        assert!(
1536            !out.data.containers.is_empty(),
1537            "expected compiled containers"
1538        );
1539    }
1540
1541    #[test]
1542    fn compile_surfaces_diagnostics_as_a_compile_error() {
1543        // Extension syntax under the default (strict-ink) dialect is rejected.
1544        let t = tree(&[("main.ink", "VAR arr = 0\n~ { arr = #[1, 2, 3] }\n-> END\n")]);
1545        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1546        let err = compile(&env).expect_err("strict-ink must reject extension syntax");
1547        assert!(matches!(err, CompileError::Diagnostics(_)));
1548    }
1549
1550    #[test]
1551    fn load_then_compile_matches_across_a_serialize_round_trip() {
1552        let t = tree(&[("main.ink", "Hello.\n-> END\n")]);
1553        let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1554        let json = serde_json::to_string(&env).expect("serializes");
1555        let back: Environment = serde_json::from_str(&json).expect("deserializes");
1556
1557        let a = compile(&env).expect("compiles");
1558        let b = compile(&back).expect("compiles from round-tripped env");
1559        // Same input value → same compiled bytes.
1560        let mut buf_a = String::new();
1561        let mut buf_b = String::new();
1562        brink_format::write_inkt(&a.data, &mut buf_a).expect("inkt a");
1563        brink_format::write_inkt(&b.data, &mut buf_b).expect("inkt b");
1564        assert_eq!(buf_a, buf_b);
1565    }
1566}