Skip to main content

bynk_emit/
project.rs

1//! Multi-file project compilation (v0.3 §3.2 and §3.3, v0.4 §3.5).
2//!
3//! A "project" is a directory tree of `.bynk` source files. The dotted name
4//! of a commons or context (e.g., `bynk.time`, `commerce.orders`) maps to a
5//! path under the project root — either a single file (`bynk/time.bynk`) or
6//! a directory of files all sharing the same header (`bynk/time/*.bynk`).
7//!
8//! v0.4: each file is one of two kinds — commons or context. Both kinds share
9//! the same multi-file directory machinery; they differ in body content
10//! (contexts have `consumes`/`exports`, types are nominally per-context), in
11//! visibility (contexts export only the types listed), and in TypeScript
12//! emission (contexts re-brand types from used commons).
13//!
14//! Compilation proceeds in two passes:
15//!   1. **Discover and parse** every `.bynk` file. Group by qualified name
16//!      and kind. Build a global symbol table where each unit contributes
17//!      its declarations.
18//!   2. **Resolve, type-check, and emit** each unit with full visibility of
19//!      the units it transitively `uses` or `consumes`. Two passes keep
20//!      `uses` cycles trivial — there is no order-of-evaluation, only
21//!      declarative mixin.
22
23use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
24use std::path::{Component, Path, PathBuf};
25use std::sync::Arc;
26
27use crate::emitter;
28use crate::ir::CapRefIr;
29use crate::ir::lower::{lower_handler_given_ir, lower_provider_given_ir};
30use bynk_check::check_pipeline::{self, prepare_unit_check_ctx};
31use bynk_check::checker;
32use bynk_check::checker::{TyId, Types};
33use bynk_check::expr_types::ExprTypeSink;
34use bynk_check::firstparty::{self, Platform};
35use bynk_check::hints::HintSink;
36use bynk_check::index::{ProjectIndex, RefSink};
37use bynk_check::locals::LocalsSink;
38use bynk_check::project_model::{
39    self, AdapterBinding, UnitInfo, handler_cross_caps, resolve_consume_prefix,
40};
41use bynk_check::requirements::RequirementSink;
42use bynk_check::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
43use bynk_syntax::ast::ExprId;
44use bynk_syntax::ast::*;
45use bynk_syntax::error::CompileError;
46use bynk_syntax::lexer;
47use bynk_syntax::parser;
48
49// P4.0 (#1113): discovery, the unit graph, path resolution, and cross-unit
50// consistency checks moved to `bynk-project` — this crate is now a
51// dependent, not an owner, of that code (`design/tracks/project-model.md`
52// §6 row P4.0). P4.1 (#1115): `symbols` and the per-file `context_checks`
53// subset of `validate` moved to `bynk-check` for the same reason — this
54// crate reaches them as `bynk_check::symbols`/`bynk_check::context_checks`
55// now. P5.0-P5.3 (`design/tracks/semantics-in-the-checker.md` §6) emptied
56// `validate` out the same way, project-wide check by project-wide check,
57// including the reconciliation half of `schema_registry` (now
58// `bynk_check::schema_registry`). P5.4 moved `tests_emit`'s checking half
59// (target/participant resolution, `stub` resolution, case/property body
60// type-checking) to `bynk_check::test_suites` the same way — `tests_emit`
61// stays here as a caller of it, holding only TypeScript emission plus the
62// two functions' unchanged public shape. P5.5 (§5, §6): `validate` reached
63// empty (its last occupant, `check_platform_lock`, left at P5.3) and its
64// remaining diagnostic-emitting sites — the `bynk.secrets.computed_name`
65// warning and the `bynk.project.schema_registry_corrupt` construction, both
66// outside the seven-category accounting — relocated too (§3.2's "eighth
67// site" and §9's open risk respectively); `validate.rs` and its module
68// declaration are deleted, this track's own completion criterion (§5).
69// Pipeline-driving types (`diagnostics`'s `Mode`/`ErrorSink`/
70// `ProjectAnalysis`/`ProjectFailure`) stay here too.
71mod diagnostics;
72mod schema_registry;
73mod tests_emit;
74
75use bynk_check::symbols::*;
76use bynk_project::discovery::*;
77use bynk_project::paths::*;
78use diagnostics::*;
79use tests_emit::*;
80
81// External facade: items referenced as `crate::project::X` from outside this
82// module (emitter, main, lib) must stay reachable at that path.
83pub use bynk_check::project_model::BuildTarget;
84pub use bynk_check::symbols::{FileDeclIndex, UnitTable};
85pub use bynk_project::{
86    AttributedError, ProjectPaths, ProjectPathsError, Roots, SchemaLock, UnitKind,
87    discover_project_files, try_read_project_paths, try_read_project_paths_with, worker_dir_name,
88    worker_handlers_output_path, worker_handlers_source_path,
89};
90pub use diagnostics::{ContextBoundaryInfo, ContextSequenceInfo, ProjectAnalysis, ProjectFailure};
91
92/// One generated TypeScript file.
93pub struct CompiledFile {
94    /// The originating Bynk source file, relative to the project root.
95    pub source_path: PathBuf,
96    /// Where the TS output should be written, relative to the output root.
97    /// Mirrors the source tree, with `.bynk` rewritten to `.ts`.
98    pub output_path: PathBuf,
99    /// The emitted TypeScript content.
100    pub typescript: String,
101    /// Slice 1 (ADR 0103): the serialised source-map v3 document for this file,
102    /// when one was produced (the emitted `.bynk`-sourced units). `None` for
103    /// generated glue and config (runtime, compose, worker entry, `wrangler.toml`,
104    /// `package.json`, adapter bindings) — those have no `.bynk` to map back to.
105    /// `write_output` writes it as a sibling `.ts.map` and appends the
106    /// `//# sourceMappingURL` trailer; the in-memory `typescript` stays
107    /// trailer-free, so golden comparisons are unaffected.
108    pub source_map: Option<String>,
109    /// Slice 3 (semantic-debugging track, ADR 0105): the debug-metadata sidecar —
110    /// a JSON `{ fn → Bynk-operation-label }` map so the debugger names stack
111    /// frames `GET "/"` rather than `http_GET`. `Some` only for `.bynk` units that
112    /// declare handlers; `write_output` writes it as a sibling `.bynkdbg.json`.
113    pub debug_metadata: Option<String>,
114}
115
116/// Result of compiling a project.
117pub struct ProjectOutput {
118    pub files: Vec<CompiledFile>,
119    /// v0.89 (ADR 0117): non-failing warnings emitted on a successful build —
120    /// surfaced (the CLI prints them, the LSP shows them) but not gating.
121    pub warnings: Vec<AttributedError>,
122    /// Per-file source snapshots, keyed the same way `ProjectFailure::snapshots`
123    /// is — lets a warning render with real file/line/col context (ariadne or
124    /// `path:line:col:`) instead of the position-free `warning[category]: …`
125    /// fallback a successful build previously had no way to avoid.
126    pub snapshots: Vec<(PathBuf, String)>,
127    /// v0.67: the test manifest — every discovered suite and case, retained at
128    /// emit time so `bynkc test --no-run --format json` can render a discovery
129    /// document without running the suite. Built from the same names + spans the
130    /// runner would emit at `suite-begin`/`case`, so a discovery document
131    /// reconciles cleanly against a later run's document (same suite name/kind,
132    /// same case names). Ordered to match the runner (`emit_test_main`).
133    pub discovered: Vec<DiscoveredSuite>,
134    /// #1078: the reconciled `bynk.schema.lock` content, `Some` whenever
135    /// `CompileOptions::schema_registry` was `SchemaLock::On` for this build
136    /// — `bynk-emit` computes it but never writes it; the caller (today,
137    /// `bynk-driver`'s two wiring points) persists it atomically, exactly
138    /// the discipline `schema_registry.rs` used to implement itself. `None`
139    /// when the registry was off, regardless of whether the content would
140    /// have changed — the unchanged-content no-op lives in the writer, not
141    /// here, so a caller that reconciles the same shape twice in a row still
142    /// gets `Some` both times.
143    pub schema_lock: Option<String>,
144}
145
146/// v0.67: a discovered test suite — one `test <target>` group (unit) or
147/// `test integration "<suite>"` (integration). `name` + `kind` mirror exactly
148/// what the NDJSON runner emits at `suite-begin` (kind `"unit"` carries the
149/// joined target name; `"integration"` carries the bare suite name), so the
150/// editor reconciles discovery and run documents to the same tree items.
151#[derive(Debug, Clone, PartialEq)]
152pub struct DiscoveredSuite {
153    pub name: String,
154    pub kind: &'static str,
155    pub cases: Vec<DiscoveredCase>,
156}
157
158/// One discovered `test "<name>"` case. `location` points at the case-name
159/// literal (a run *failure* instead points at the failing `assert`), giving the
160/// editor click-through to the declaration before any run.
161#[derive(Debug, Clone, PartialEq)]
162pub struct DiscoveredCase {
163    pub name: String,
164    pub location: Option<TestLocation>,
165}
166
167/// A project-root-relative `path:line:col` source location, structured. Line and
168/// col are 1-indexed (the [`bynk_syntax::span::line_col`] convention).
169#[derive(Debug, Clone, PartialEq)]
170pub struct TestLocation {
171    pub path: String,
172    pub line: u32,
173    pub col: u32,
174}
175
176// P4.1 (#1115): `AdapterBinding` and `BuildTarget` relocated to
177// `bynk-check::project_model` alongside `phase_group`, which constructs
178// them — see that module's own doc comment. `BuildTarget` is re-exported
179// below at its old `crate::project::BuildTarget` path (part of this crate's
180// public surface); `AdapterBinding` was never public here, so a plain `use`
181// (above) is enough.
182
183/// The extension emitted import specifiers use (`import … from "./x.<ext>"`).
184///
185/// `Js` is the default and the only shape for normal builds: NodeNext resolution
186/// and `tsc` require `.js` specifiers even though the sources are `.ts`. `Ts` is
187/// the **debug build** (slice 2, ADR 0104): `bynkc test --inspect` runs the
188/// emitted `.ts` directly under Node's line-preserving strip-only type-stripping,
189/// where slice 1's source maps apply unchanged — but Node will not resolve a `.js`
190/// specifier to the `.ts` on disk, so the debug build emits `.ts` specifiers.
191#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
192pub enum ImportExt {
193    #[default]
194    Js,
195    Ts,
196}
197
198impl ImportExt {
199    /// The bare extension string (`"js"` / `"ts"`), for `Path::with_extension`
200    /// and specifier formatting.
201    pub fn as_str(self) -> &'static str {
202        match self {
203            ImportExt::Js => "js",
204            ImportExt::Ts => "ts",
205        }
206    }
207}
208
209/// Options for [`compile_project`]. Construct with [`CompileOptions::single`] or
210/// [`CompileOptions::split`], then chain `.target(…)` / `.platform(…)` /
211/// `.import_ext(…)` to override the bundle/default-platform/`.js` defaults.
212#[derive(Clone)]
213pub struct CompileOptions {
214    pub target: BuildTarget,
215    pub platform: Platform,
216    pub roots: Roots,
217    /// The import-specifier extension (slice 2). `Js` (default) for normal builds;
218    /// `Ts` for the `bynkc test --inspect` debug build.
219    pub import_ext: ImportExt,
220    /// v0.115 (testing track slice 3, DECISION J): the build profile for function
221    /// contracts. `true` (dev/test) emits the call-site guard around a contracted
222    /// `fn`; `false` (release/deploy) strips it entirely for zero runtime cost.
223    /// `bynkc test` and `--inspect` set it on; `bynkc compile` leaves it off.
224    pub contracts: bool,
225    /// #57 (testing track): when `Some`, every file `roots` would otherwise
226    /// discover on disk is instead read from here — keyed the same way
227    /// `discovery::read_source`'s overlay is (a canonicalised absolute path,
228    /// falling back to the literal path when the file has no on-disk
229    /// counterpart to canonicalise). Filesystem discovery is skipped entirely;
230    /// `roots` still supplies `src_root`/`tests_root` and their prefixes, so a
231    /// `Roots::Split` project can drive both trees in-memory.
232    ///
233    /// #1077/#1081: `bynk-driver`'s `project_options`/`try_project_options`
234    /// *do* set this now — the real CLI entry points walk and read every
235    /// project file themselves and hand the result here, so `bynk-emit`
236    /// itself no longer discovers or reads anything on disk for the CLI
237    /// path. `bynkc`/the LSP still don't: `bynkc` routes through
238    /// `bynk-driver`, and the LSP (`analyse_project_with`) has its own
239    /// open-buffer overlay and depends on the on-disk fallback for the rest
240    /// (#1079). `bynk-emit`'s own tests also set this, to exercise the full
241    /// project pipeline (cross-context `uses`, multi-file layouts,
242    /// workers-mode emission, …) without an on-disk fixture tree.
243    pub sources: Option<HashMap<PathBuf, String>>,
244    /// Events track, slice 3c (#980): reconcile against `bynk.schema.lock`.
245    /// **Off by default** — `bynkc compile`'s directory branch and `bynk`'s
246    /// deploy/dev build turn it on; every library/test caller (in-memory
247    /// builds, `bynkc/tests/e2e.rs`'s in-place fixture compiles, `bynk-emit`'s
248    /// own `sources`-driven tests, the LSP) leaves it off, so a compile never
249    /// mutates a project tree it wasn't asked to.
250    ///
251    /// #1078: this carries the lock's pre-read content, not just an on/off
252    /// switch — `bynk-emit` reads and writes no disk for this file (the same
253    /// move #1077/#1081 already made for `.bynk` source content). See
254    /// [`SchemaLock`]'s own doc for the read side; the reconciled content
255    /// comes back out on [`ProjectOutput::schema_lock`] for the caller to
256    /// persist.
257    pub schema_registry: SchemaLock,
258}
259
260impl CompileOptions {
261    /// Single-root project (`src == tests`), bundle target, default platform.
262    pub fn single(root: impl Into<PathBuf>) -> Self {
263        Self {
264            target: BuildTarget::Bundle,
265            platform: Platform::default(),
266            roots: Roots::Single(root.into()),
267            import_ext: ImportExt::default(),
268            contracts: false,
269            sources: None,
270            schema_registry: SchemaLock::Off,
271        }
272    }
273
274    /// v0.9.1 split layout (source and test units in separate subdirectories
275    /// under `project_root`), bundle target, default platform. Use this from
276    /// `bynkc test` so its rooting matches `bynkc compile`'s.
277    pub fn split(project_root: impl Into<PathBuf>, paths: ProjectPaths) -> Self {
278        Self {
279            target: BuildTarget::Bundle,
280            platform: Platform::default(),
281            roots: Roots::Split {
282                project_root: project_root.into(),
283                paths,
284            },
285            import_ext: ImportExt::default(),
286            contracts: false,
287            sources: None,
288            schema_registry: SchemaLock::Off,
289        }
290    }
291
292    /// Select the build target. `Bundle` (default) is the v0.6+ single-bundle
293    /// layout; `Workers` (v0.8) emits per-context Cloudflare Workers.
294    pub fn target(mut self, target: BuildTarget) -> Self {
295        self.target = target;
296        self
297    }
298
299    /// Slice 2: select the import-specifier extension. `Ts` is the debug build
300    /// for `bynkc test --inspect` (run the `.ts` directly under Node strip-only).
301    pub fn import_ext(mut self, ext: ImportExt) -> Self {
302        self.import_ext = ext;
303        self
304    }
305
306    /// v0.115: enable the function-contract call-site guard (dev/test profile).
307    /// `bynkc test` and `--inspect` call this; the deploy build leaves it off so
308    /// contract checks never reach production (DECISION J).
309    pub fn contracts(mut self, on: bool) -> Self {
310        self.contracts = on;
311        self
312    }
313
314    /// v0.17: select the deploy [`Platform`] (selects the `bynk` surface
315    /// binding). The MVP ships `cloudflare` only.
316    pub fn platform(mut self, platform: Platform) -> Self {
317        self.platform = platform;
318        self
319    }
320
321    /// #57 (testing track): supply every file in-memory instead of walking
322    /// `roots` on disk — keyed the same way `discovery::read_source`'s
323    /// overlay is (see the `sources` field's own doc).
324    pub fn sources(mut self, sources: HashMap<PathBuf, String>) -> Self {
325        self.sources = Some(sources);
326        self
327    }
328
329    /// Events track, slice 3c (#980): turn on `bynk.schema.lock`
330    /// reconciliation for this build, with its pre-read content (or
331    /// verified-absent `None`, for a fresh project). See [`SchemaLock`] and
332    /// the field's own doc for who calls this and why everyone else leaves
333    /// it off.
334    pub fn schema_registry(mut self, mode: SchemaLock) -> Self {
335        self.schema_registry = mode;
336        self
337    }
338}
339
340/// #57: turn `options.sources` into the `(overlay, discovered)` pair
341/// `run_checks` expects — the caller-supplied file list, partitioned across
342/// `trees` the same way a real walk would (single-tree: every file lands in
343/// the first tree's list, matching `compile_in_memory`'s own convention).
344/// Shared by [`compile_project`] and [`check_project`] so the two can't drift
345/// on this.
346///
347/// #1077/#1081 review: sorts every partition. `sources`'s own key order is a
348/// `HashMap`'s — unspecified, randomised per process — but `phase_parse`
349/// walks each tree's file list in order to assign sequential `FileId`s and
350/// `ExprId`s (embedded in emitted spans/source maps) and `run_checks` pushes
351/// diagnostics in file order, both of which a real disk walk already
352/// guaranteed via `discover_bynk_files`'s own `out.sort()`. Without this, a
353/// `sources`-driven compile (the CLI's own path as of #1081) would silently
354/// vary its diagnostic order and `FileId` assignment run to run.
355///
356/// R3.9 (#1113): partitions across every `trees` entry, not a hardcoded
357/// primary/secondary pair — a path that doesn't start with any tree's root
358/// (shouldn't happen for a well-formed `sources` map) falls back to the
359/// *last* tree, matching the pre-R3.9 two-tree `partition`'s fallback (an
360/// unmatched key landed in `tests_files`, the second/last half of the pair).
361/// Falling back to the *first* tree instead — silently tried during this
362/// slice's initial cut — would move an unmatched file into whichever tree
363/// `check_file_directory_conflicts` and `identity_path` treat as primary,
364/// changing its attribution with no diagnostic raised either way; matching
365/// the old convention at least keeps this rare path's behaviour unchanged by
366/// the crate move.
367type Overlay = HashMap<PathBuf, String>;
368/// One file list per `trees` entry — the same shape `phase_discovery` and
369/// `run_checks`'s own `discovered` parameter already use.
370type Discovered = Vec<Vec<PathBuf>>;
371
372fn sources_to_discovered(
373    sources: &HashMap<PathBuf, String>,
374    trees: &[(PathBuf, PathBuf)],
375) -> (Overlay, Option<Discovered>) {
376    let mut buckets: Vec<Vec<PathBuf>> = vec![Vec::new(); trees.len()];
377    let mut keys: Vec<PathBuf> = sources.keys().cloned().collect();
378    keys.sort();
379    for p in keys {
380        let idx = trees
381            .iter()
382            .position(|(root, _)| p.starts_with(root))
383            .unwrap_or(trees.len().saturating_sub(1));
384        buckets[idx].push(p);
385    }
386    (sources.clone(), Some(buckets))
387}
388
389/// Compile a Bynk project, keeping error attribution + snapshots on failure
390/// (so the CLI can render project errors with source context, ADR 0052). Use
391/// `.map_err(ProjectFailure::flatten)` for the flattened `Vec<CompileError>`
392/// shape.
393pub fn compile_project(options: &CompileOptions) -> Result<ProjectOutput, ProjectFailure> {
394    // T3.6b (R4.1): one table per build, shared across every unit compiled.
395    let tys = &Arc::new(Types::new());
396    let trees = options.roots.trees();
397    let excludes = options.roots.excludes();
398    let (overlay, discovered) = match &options.sources {
399        Some(sources) => sources_to_discovered(sources, &trees),
400        None => (HashMap::new(), None),
401    };
402    let run = run_checks(
403        &trees,
404        options.target,
405        options.platform,
406        options.import_ext,
407        Mode::Build,
408        &overlay,
409        &excludes,
410        discovered,
411        options.contracts,
412        &options.schema_registry,
413        options.roots.project_root(),
414        tys,
415    );
416    // #1078: `bynk-emit` no longer writes `bynk.schema.lock` itself — the
417    // reconciled content comes back on `ProjectOutput::schema_lock`
418    // (`finish_build` populates it from `RunChecks::Checked`, only ever
419    // constructed on the `Ok` path, i.e. only on a fully clean build — a
420    // build that fails for any reason, including a schema mismatch
421    // reconciliation itself just reported, produces `Err(ProjectFailure)`
422    // instead and has no revised content for a caller to persist). The
423    // caller (today, `bynk-driver`'s two wiring points) does the atomic
424    // write.
425    finish_build(run, options.import_ext)
426}
427
428/// Result of [`check_project`]: every diagnostic from a non-bailing project
429/// analysis — errors *and* warnings together (ADR 0117), unconditionally,
430/// unlike [`ProjectOutput`]/[`ProjectFailure`] where `errors`/`warnings` is
431/// picked by which variant the caller got. `bynk check`'s exit code is
432/// decided by [`Self::has_errors`], not by whether this was reached at all.
433pub struct ProjectCheck {
434    pub errors: Vec<AttributedError>,
435    pub snapshots: Vec<(PathBuf, String)>,
436}
437
438impl ProjectCheck {
439    /// Finding #64: `bynk check`'s exit-code gate is "does any error-severity
440    /// diagnostic exist in the project" — not "did the pipeline reach the end
441    /// without bailing", which is what `compile_project`'s `Result` encoded
442    /// and why a `bynk.toml`-wide structural error anywhere silently hid every
443    /// later diagnostic (including a test body's own type errors) from
444    /// `bynk check`, even though the editor (via `analyse_project_with`, the
445    /// same `Mode::Analyse` this runs) still reported them.
446    pub fn has_errors(&self) -> bool {
447        self.errors
448            .iter()
449            .any(|ae| bynk_syntax::Severity::for_error(&ae.error) == bynk_syntax::Severity::Error)
450    }
451}
452
453/// Check a project without building (finding #64) — never bails after
454/// discovery (`Mode::Analyse`, the same mode `analyse_project_with` already
455/// uses for the editor), so a diagnostic anywhere in the project does not
456/// suppress diagnostics elsewhere. `compile_project`'s `Mode::Build` bails at
457/// the first structural error and returns only what it collected up to that
458/// point — correct for `build`/`test`, which must not emit past a real
459/// error, but wrong for `check`, whose only job is to report everything.
460/// `bynk check`'s directory path calls this instead of `compile_project`.
461pub fn check_project(options: &CompileOptions) -> ProjectCheck {
462    // T3.6b (R4.1): one table per check, shared across every unit.
463    let tys = &Arc::new(Types::new());
464    let trees = options.roots.trees();
465    let excludes = options.roots.excludes();
466    let (overlay, discovered) = match &options.sources {
467        Some(sources) => sources_to_discovered(sources, &trees),
468        None => (HashMap::new(), None),
469    };
470    let run = run_checks(
471        &trees,
472        options.target,
473        options.platform,
474        options.import_ext,
475        Mode::Analyse,
476        &overlay,
477        &excludes,
478        discovered,
479        options.contracts,
480        // `bynk check` never reconciles the schema registry, regardless of
481        // `options.schema_registry` — pre-existing behaviour (finding #64's
482        // own era), preserved as-is by #1078, not introduced by it.
483        &SchemaLock::Off,
484        options.roots.project_root(),
485        tys,
486    );
487    match run {
488        RunChecks::Bailed {
489            errors, snapshots, ..
490        }
491        | RunChecks::Checked {
492            errors, snapshots, ..
493        } => ProjectCheck {
494            errors: errors.into_all(),
495            snapshots,
496        },
497    }
498}
499
500/// Compile a single **in-memory** Bynk source through the full project pipeline —
501/// no filesystem access (in-browser track, slice 3). The source is the in-process
502/// `Bundle` subset that `consumes bynk`; first-party injection and the per-platform
503/// binding emission run exactly as for an on-disk build, so the returned
504/// [`ProjectOutput`] is the complete module graph (the user unit + `runtime.ts` +
505/// the `bynk-<platform>.ts` binding + `compose.ts`). The wasm entry point pairs
506/// this with `bynk-strip` to produce JavaScript for the playground.
507///
508/// The module's logical path is **derived from its declared unit name** (a context
509/// `app.demo` ⇒ `app/demo.bynk`), so the name↔path alignment check passes without
510/// real files; a source that does not parse falls back to `main.bynk` and the parse
511/// error is reported normally.
512pub fn compile_in_memory(
513    source: &str,
514    target: BuildTarget,
515    platform: Platform,
516) -> Result<ProjectOutput, ProjectFailure> {
517    // T3.6b (R4.1): one table for this virtual project's compile.
518    let tys = &Arc::new(Types::new());
519    // A single-tree (`src_root == tests_root`) virtual project rooted at `.`: the
520    // one source file is supplied directly and its text layered in via the
521    // overlay, so discovery and every other disk read are bypassed.
522    let root = PathBuf::from(".");
523    let path = in_memory_logical_path(source);
524    let mut overlay = HashMap::new();
525    overlay.insert(path.clone(), source.to_string());
526    let trees = vec![(root.clone(), PathBuf::new())];
527    let run = run_checks(
528        &trees,
529        target,
530        platform,
531        ImportExt::Js,
532        Mode::Build,
533        &overlay,
534        &[],
535        Some(vec![vec![path]]),
536        false,
537        &SchemaLock::Off,
538        &root,
539        tys,
540    );
541    finish_build(run, ImportExt::Js)
542}
543
544/// Analyse a single **in-memory** Bynk source and return all diagnostics —
545/// non-bailing, no emission (in-browser track, slice 5d). The editor calls this
546/// on every (debounced) keystroke for live diagnostics: unlike [`compile_in_memory`]
547/// (build mode, which bails at the first failing phase), this runs in `Analyse`
548/// mode, so parse / resolve / check diagnostics are recovered and reported together
549/// — and it works for a `context` (the playground's typical program), not only a
550/// commons. Same fs-free seam as `compile_in_memory`.
551pub fn analyse_in_memory(
552    source: &str,
553    target: BuildTarget,
554    platform: Platform,
555) -> Vec<AttributedError> {
556    analyse_in_memory_with_types(source, target, platform).errors
557}
558
559/// The outcome of [`analyse_in_memory_with_types`]: diagnostics plus the
560/// analysed file's `(span, type)` entries (span-sorted — see
561/// [`ExprTypeSink::take_files`]), for a position→type query (#397, the
562/// playground's hover), and its local bindings (#808, the playground's
563/// completion — `bynk_check::locals::locals_at` over `locals` answers
564/// "what's in scope at this offset").
565pub struct InMemoryAnalysis {
566    pub errors: Vec<AttributedError>,
567    pub expr_types: Vec<(bynk_syntax::span::Span, TyId)>,
568    /// T3.6b (R4.1): the table `expr_types`' ids resolve against.
569    pub ty_intern: std::sync::Arc<bynk_check::checker::Types>,
570    pub locals: Vec<bynk_check::locals::LocalBinding>,
571}
572
573/// Like [`analyse_in_memory`], but also exposes the expression-type map the
574/// checker captured (ADR 0063's `expr_types` sink) — the same one
575/// [`analyse_project_with`] drains — instead of discarding it. Per ADR 0094,
576/// this is a best-effort **partial** map in `Analyse` mode: a function that
577/// type-checked cleanly contributes its types even if a *different* function
578/// in the same file has an error, so `expr_types` is empty only when the
579/// expression at hand never typed at all (e.g. it sits in an unresolved
580/// region, ADR 0094's "out of scope — the resolve gate"), not merely because
581/// the file has some error somewhere.
582pub fn analyse_in_memory_with_types(
583    source: &str,
584    target: BuildTarget,
585    platform: Platform,
586) -> InMemoryAnalysis {
587    // T3.6b (R4.1): one table for the whole analysis — every unit it checks
588    // interns into this, so the `TyId`s it hands back on `InMemoryAnalysis`
589    // all resolve against the one table it also hands back.
590    let tys = &Arc::new(Types::new());
591    let root = PathBuf::from(".");
592    let path = in_memory_logical_path(source);
593    let mut overlay = HashMap::new();
594    overlay.insert(path.clone(), source.to_string());
595    let trees = vec![(root.clone(), PathBuf::new())];
596    let run = run_checks(
597        &trees,
598        target,
599        platform,
600        ImportExt::Js,
601        Mode::Analyse,
602        &overlay,
603        &[],
604        Some(vec![vec![path.clone()]]),
605        false,
606        &SchemaLock::Off,
607        &root,
608        tys,
609    );
610    match run {
611        RunChecks::Bailed {
612            errors,
613            mut exprs,
614            mut locals,
615            ..
616        }
617        | RunChecks::Checked {
618            errors,
619            mut exprs,
620            mut locals,
621            ..
622        } => InMemoryAnalysis {
623            errors: errors.into_all(),
624            expr_types: exprs.take_files().remove(&path).unwrap_or_default(),
625            ty_intern: Arc::clone(tys),
626            locals: locals.take_files().remove(&path).unwrap_or_default(),
627        },
628    }
629}
630
631/// Derive the conventional single-file path for an in-memory source from its
632/// declared unit name (`app.demo` ⇒ `app/demo.bynk`), so `check_path_name_alignment`
633/// is satisfied without a real file tree. Falls back to `main.bynk` when the source
634/// does not parse — `run_checks` then re-parses and reports the error against it.
635fn in_memory_logical_path(source: &str) -> PathBuf {
636    let parts: Option<Vec<String>> = lexer::tokenize(source)
637        .ok()
638        .and_then(|tokens| parser::parse_unit(&tokens, source).ok())
639        .map(|unit| {
640            let name = match &unit {
641                SourceUnit::Commons(c) => &c.name,
642                SourceUnit::Context(c) => &c.name,
643                SourceUnit::Adapter(a) => &a.name,
644                SourceUnit::Suite(t) => &t.target,
645            };
646            name.parts.iter().map(|i| i.name.clone()).collect()
647        });
648    match parts {
649        Some(p) if !p.is_empty() => {
650            let mut path = PathBuf::from(p.join("/"));
651            path.set_extension("bynk");
652            path
653        }
654        _ => PathBuf::from("main.bynk"),
655    }
656}
657
658/// Assemble a finished [`ProjectOutput`] (or a [`ProjectFailure`]) from a
659/// [`RunChecks`] result — the shared tail of `compile_project` and
660/// `compile_in_memory`.
661fn finish_build(run: RunChecks, import_ext: ImportExt) -> Result<ProjectOutput, ProjectFailure> {
662    match run {
663        RunChecks::Bailed {
664            errors, snapshots, ..
665        } => Err(ProjectFailure {
666            // ADR 0117: a failed build still renders any warnings it produced
667            // (the sink yields errors then warnings).
668            errors: errors.into_all(),
669            snapshots,
670        }),
671        RunChecks::Checked {
672            errors, snapshots, ..
673        } if !errors.is_empty() => Err(ProjectFailure {
674            errors: errors.into_all(),
675            snapshots,
676        }),
677        RunChecks::Checked {
678            errors,
679            snapshots,
680            parsed,
681            compiled,
682            runnable_tests,
683            integration_outputs,
684            integration_runnables,
685            groups,
686            kinds,
687            unit_consumes,
688            unit_consumes_aliases,
689            unit_tables,
690            unit_callees,
691            unit_uses,
692            unit_flattened,
693            adapter_bindings,
694            npm_deps,
695            target,
696            schema_registry,
697            ..
698        } => {
699            let mut out = build_output(
700                parsed,
701                compiled,
702                runnable_tests,
703                integration_outputs,
704                integration_runnables,
705                groups,
706                kinds,
707                unit_consumes,
708                unit_consumes_aliases,
709                unit_tables,
710                unit_callees,
711                unit_uses,
712                unit_flattened,
713                adapter_bindings,
714                npm_deps,
715                target,
716                import_ext,
717            );
718            // ADR 0117: surface non-failing warnings on the successful build
719            // (errors is empty here — the guard arm above caught any).
720            out.warnings = errors.into_warnings();
721            out.snapshots = snapshots;
722            // #1078: the reconciled registry, if this build had one on —
723            // bynk-emit computes it, the caller persists it.
724            out.schema_lock = schema_registry.map(|reg| schema_registry::serialize(&reg));
725            Ok(out)
726        }
727    }
728}
729
730/// v0.24: analyse a project without building — non-bailing, overlay-aware,
731/// file-attributed (ADR 0052). `overlay` maps canonicalised absolute paths
732/// to buffer text layered over disk reads (unsaved editor buffers).
733///
734/// Slice A: the single-tree convenience over [`analyse_project_with`]
735/// (`Roots::Single`), preserving the pre-slice-A behaviour for callers that
736/// hand in one fixture root and want one tree walked.
737pub fn analyse_project(root: &Path, overlay: &HashMap<PathBuf, String>) -> ProjectAnalysis {
738    analyse_project_with(&Roots::Single(root.to_path_buf()), overlay)
739}
740
741/// Slice A: analyse a project whose roots are resolved from its manifest — the
742/// same [`Roots`] `compile_project` consumes, resolved the same way, so the LSP
743/// discovers exactly the files `bynkc` compiles.
744///
745/// Identity is project-relative (ADR 0198): a file's `source_path` here is
746/// unique across `include` roots.
747pub fn analyse_project_with(roots: &Roots, overlay: &HashMap<PathBuf, String>) -> ProjectAnalysis {
748    // T3.6b (R4.1): see `analyse_in_memory_with_types` — one table per
749    // analysis, shared by every unit, carried out on the result.
750    let tys = &Arc::new(Types::new());
751    // Resolved exactly as `compile_project` does — one project model, not two.
752    let trees = roots.trees();
753    let excludes = roots.excludes();
754    match run_checks(
755        &trees,
756        BuildTarget::Bundle,
757        Platform::default(),
758        ImportExt::Js,
759        Mode::Analyse,
760        overlay,
761        &excludes,
762        None,
763        false,
764        // The LSP never reconciles the schema registry (#1079's open scope
765        // covers the editor becoming a real file-content owner generally;
766        // this specific flag was already, and stays, hardcoded off).
767        &SchemaLock::Off,
768        roots.project_root(),
769        tys,
770    ) {
771        RunChecks::Bailed {
772            errors,
773            snapshots,
774            mut hints,
775            mut locals,
776            mut exprs,
777            mut requirements,
778        } => ProjectAnalysis {
779            snapshots,
780            // ADR 0117: the LSP renders warnings alongside errors (severity is
781            // applied downstream), so analyse surfaces the full diagnostic list.
782            errors: errors.into_all(),
783            index: ProjectIndex::default(),
784            hints: hints.take_files(),
785            locals: locals.take_files(),
786            expr_types: exprs.take_files(),
787            ty_intern: Arc::clone(tys),
788            requirements: requirements.take_files(),
789            // No parsed tree on the bail path — the map stays empty (ADR 0095).
790            unit_sources: HashMap::new(),
791            // #846: same bail rule as `unit_sources` — nothing was resolved.
792            sequence_info: HashMap::new(),
793            // #855: same bail rule — nothing was resolved.
794            boundary_info: HashMap::new(),
795            // #848: no parsed tree on the bail path either.
796            doc_scope: HashMap::new(),
797        },
798        RunChecks::Checked {
799            errors,
800            snapshots,
801            mut refs,
802            mut hints,
803            mut locals,
804            mut exprs,
805            mut requirements,
806            parsed,
807            unit_uses,
808            unit_consumes,
809            unit_consumes_aliases,
810            unit_tables,
811            unit_flattened,
812            kinds,
813            ..
814        } => {
815            let index = assemble_index(
816                &parsed,
817                &unit_uses,
818                &unit_consumes,
819                std::mem::take(&mut refs),
820            );
821            // ADR 0095: qualified unit name → its project source file(s), in
822            // discovery order. Synthetic (toolchain-injected `bynk` surface)
823            // units have no openable file and are excluded.
824            let mut unit_sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
825            for pf in &parsed {
826                if pf.is_synthetic() {
827                    continue;
828                }
829                unit_sources
830                    .entry(pf.unit().name().joined())
831                    .or_default()
832                    .push(pf.identity_path());
833            }
834            // #846: qualified context/adapter unit name → the cross-context +
835            // agent tables the sequence-diagram classifier needs. Rebuilt from
836            // the same retained per-project tables the per-file checking pass
837            // used to build its own transient `cross_context_for_file` (see
838            // the call site of `build_cross_context_info` above, in the
839            // per-file loop) — that transient value is never itself kept
840            // around, so this re-derives it once per unit instead of once per
841            // file, from data `run_checks` already retains.
842            let mut sequence_info: HashMap<String, ContextSequenceInfo> = HashMap::new();
843            // #855: qualified context/adapter unit name → the combined type
844            // table plus service/agent tables the wire-contract peek needs —
845            // built alongside `sequence_info` in the same loop iteration so
846            // `table`/`unit_tables`/`unit_uses` are already in scope. Uses
847            // `combined_types_for`, the same table `own_contract_hashes`
848            // hashes through, so the peek's hash and the emitted
849            // `X-Bynk-Contract` constant cannot disagree.
850            let mut boundary_info: HashMap<String, ContextBoundaryInfo> = HashMap::new();
851            for (name, kind) in &kinds {
852                if !matches!(kind, UnitKind::Context | UnitKind::Adapter) {
853                    continue;
854                }
855                let Some(table) = unit_tables.get(name) else {
856                    continue;
857                };
858                let mut cross_context = build_cross_context_info(
859                    name,
860                    &unit_consumes,
861                    &unit_consumes_aliases,
862                    &unit_uses,
863                    &unit_tables,
864                );
865                cross_context.flattened_caps =
866                    unit_flattened.get(name).cloned().unwrap_or_default();
867                sequence_info.insert(
868                    name.clone(),
869                    ContextSequenceInfo {
870                        cross_context,
871                        agents: table.agents.clone(),
872                    },
873                );
874                boundary_info.insert(
875                    name.clone(),
876                    ContextBoundaryInfo {
877                        types: bynk_check::symbols::combined_types_for(
878                            name,
879                            &unit_tables,
880                            &unit_uses,
881                        ),
882                        services: table.services.clone(),
883                        agents: table.agents.clone(),
884                    },
885                );
886            }
887            // #848: doc_scope reuses unit_sources' key set (production units —
888            // the only files a doc comment can live in) and the
889            // unit_uses/unit_consumes already destructured above for
890            // assemble_index — itself first, then its `uses` targets, then
891            // its `consumes` targets, mirroring IndexBuilder::qualify_with's
892            // bare-name search order.
893            let mut doc_scope: HashMap<String, Vec<String>> = HashMap::new();
894            for name in unit_sources.keys() {
895                let mut scope = vec![name.clone()];
896                scope.extend(unit_uses.get(name).cloned().unwrap_or_default());
897                scope.extend(unit_consumes.get(name).cloned().unwrap_or_default());
898                doc_scope.insert(name.clone(), scope);
899            }
900            ProjectAnalysis {
901                snapshots,
902                errors: errors.into_all(),
903                index,
904                hints: hints.take_files(),
905                locals: locals.take_files(),
906                expr_types: exprs.take_files(),
907                ty_intern: Arc::clone(tys),
908                requirements: requirements.take_files(),
909                unit_sources,
910                sequence_info,
911                boundary_info,
912                doc_scope,
913            }
914        }
915    }
916}
917
918// P4.1 (#1115): `normalize_service_defaults`/`inject_service_defaults`
919// relocated to `bynk-check::project_model` (called from both `run_checks`
920// here and the new `bynk-check`-native analysis entry point, ahead of
921// `phase_group` in both).
922
923/// v0.54 (#655): whether a context's services declare an `on call … by c: Caller`
924/// handler, whose emitted `deps` carries the calling context's qualified name as
925/// its `CallerId` identity (ADR 0092); in bundle mode the compose root supplies
926/// that name to `makeSurface`, mirroring the `X-Bynk-Caller` header a Worker
927/// reads at its entry. Delegates to the *same*
928/// [`any_service_binds_caller`](crate::emitter::any_service_binds_caller) the
929/// emitter's `emit_make_surface` calls, so the compose root and the surface can
930/// never disagree on which providers take the extra `__caller` argument.
931fn context_binds_caller(table: &UnitTable) -> bool {
932    crate::emitter::any_service_binds_caller(table.services.values(), &table.actors)
933}
934
935/// A double-quoted, escaped TypeScript string literal for `s` (a qualified
936/// context name at the compose-root caller sites).
937fn ts_string_literal(s: &str) -> String {
938    format!("\"{}\"", crate::emitter::escape_ts_string(s))
939}
940
941// P4.1 (#1115): `record_analyse_types` relocated to
942// `bynk-check::check_pipeline` (called from `check_file_core`'s own
943// error-path exits, plus every clean-path caller — `check_unit_files`'s
944// `Mode::Analyse` branch below and the new entry point's own).
945
946// P4.1 (#1115): the whole discovery->parse->group->resolve pipeline
947// (`phase_discovery` through `phase_file_index`/`assemble_unit_info`, plus
948// per-unit symbol composition — `compose_unit_symbols`/
949// `merge_consumed_exports`/`collect_unit_methods`) relocated to
950// `bynk-check::project_model` — see that module's own doc comment for why
951// (the same `extract, don't duplicate` move `bynk-project` itself was, P4.0).
952// `run_checks`, below, is now a caller of `project_model::phase_*` instead of
953// owning this logic inline.
954
955/// v0.119 (ADR 0155): the agents a `for all run: History[Agent]` property drives,
956/// scanned across every test suite in the project. `emit_agent` gates the
957/// exported `__bynkDriveHistory_<Agent>` driver on membership, so a non-targeted
958/// agent's emission is unchanged.
959fn collect_history_target_agents(parsed: &[ParsedFile]) -> HashSet<String> {
960    let mut set = HashSet::new();
961    for pf in parsed {
962        let Some(test) = pf.test() else { continue };
963        for prop in &test.properties {
964            for b in &prop.forall.bindings {
965                if let TypeRef::History(inner, _) = &b.type_ref
966                    && let TypeRef::Named(id) = &**inner
967                {
968                    set.insert(id.name.clone());
969                }
970            }
971        }
972    }
973    set
974}
975
976/// Phase 8e: build the emitter context for one checked source file and render
977/// its TypeScript, pushing the result onto `compiled`. Reached only in build
978/// mode (the caller's analyse-mode `continue` gates this off); the block is
979/// straight-line with no `continue`s of its own.
980#[allow(clippy::too_many_arguments)]
981/// Emit-prologue tables that depend only on the *unit* (`name`/`unit_info`/
982/// `target`) — never on which file within the unit is being emitted. Building
983/// one of these once per unit, ahead of the per-file loop, replaces what used
984/// to be an identical rebuild (several nested nested loops over `unit_info`)
985/// on every emitted file of a multi-file context.
986struct EmitUnitCtx {
987    imported_methods: HashMap<String, Vec<FnDecl>>,
988    /// The workers-mode-rewritten view is the only one `emit_unit` reads —
989    /// the pre-rewrite table is an intermediate of computing it, not exposed
990    /// separately.
991    imported_decl_paths_emit: HashMap<String, HashMap<String, PathBuf>>,
992    exports_for_consumed: HashMap<String, HashMap<String, Visibility>>,
993    file_decl_index: FileDeclIndex,
994}
995
996fn build_emit_unit_ctx(
997    name: &str,
998    unit_info: &BTreeMap<String, UnitInfo>,
999    target: BuildTarget,
1000) -> EmitUnitCtx {
1001    let info = &unit_info[name];
1002    // v0.132.1 (#481): gather the attached methods of every `uses`-imported type
1003    // (one level, matching the symbol-table merge). `emit_context_rebrands`
1004    // forwards these onto the consumer's rebranded const so a call like
1005    // `Cents.fromInt(n)` type-checks. Sorted by method name for deterministic
1006    // emission (the resolver stores instance/static methods in `HashMap`s).
1007    let mut imported_methods: HashMap<String, Vec<FnDecl>> = HashMap::new();
1008    for t in &info.uses {
1009        let Some(used) = unit_info.get(t) else {
1010            continue;
1011        };
1012        for (type_name, mt) in &used.table.methods {
1013            let entry = imported_methods.entry(type_name.clone()).or_default();
1014            entry.extend(mt.instance.values().map(|f| f.as_ref().clone()));
1015            entry.extend(mt.statics.values().map(|f| f.as_ref().clone()));
1016        }
1017    }
1018    let method_key = |f: &FnDecl| match &f.name {
1019        FnName::Method { method_name, .. } => method_name.name.clone(),
1020        FnName::Free(id) => id.name.clone(),
1021    };
1022    for decls in imported_methods.values_mut() {
1023        decls.sort_by_key(&method_key);
1024    }
1025    let mut imported_decl_paths: HashMap<String, HashMap<String, PathBuf>> = HashMap::new();
1026    for t in &info.uses {
1027        if let Some(target_info) = unit_info.get(t) {
1028            let target_index = &target_info.file_index;
1029            let mut paths: HashMap<String, PathBuf> = HashMap::new();
1030            for (n, p) in &target_index.types {
1031                paths.insert(n.clone(), p.clone());
1032            }
1033            for (n, p) in &target_index.fns {
1034                paths.insert(n.clone(), p.clone());
1035            }
1036            imported_decl_paths.insert(t.clone(), paths);
1037        }
1038    }
1039    for t in &info.consumes {
1040        if let Some(target_info) = unit_info.get(t) {
1041            let target_index = &target_info.file_index;
1042            let mut paths: HashMap<String, PathBuf> = HashMap::new();
1043            // Only expose exported names — the emitter needs to know
1044            // which file declares them so it can render the import.
1045            let exports_for_target = &target_info.exports;
1046            for n in exports_for_target.keys() {
1047                if let Some(p) = target_index.types.get(n) {
1048                    paths.insert(n.clone(), p.clone());
1049                }
1050            }
1051            imported_decl_paths.insert(t.clone(), paths);
1052        }
1053    }
1054
1055    let exports_for_consumed = info
1056        .consumes
1057        .iter()
1058        .map(|t| {
1059            (
1060                t.clone(),
1061                unit_info
1062                    .get(t)
1063                    .map(|i| i.exports.clone())
1064                    .unwrap_or_default(),
1065            )
1066        })
1067        .collect();
1068
1069    // In workers mode, rewrite imported_decl_paths for consumed
1070    // contexts to point at the consumed Worker's handlers.ts.
1071    let mut imported_decl_paths_emit = imported_decl_paths.clone();
1072    if matches!(target, BuildTarget::Workers) {
1073        for (unit, decls) in imported_decl_paths.iter() {
1074            let target_kind = unit_info.get(unit).map(|i| i.kind);
1075            if target_kind == Some(UnitKind::Context) {
1076                let handlers_path = worker_handlers_source_path(unit);
1077                let mut rewritten = HashMap::new();
1078                for n in decls.keys() {
1079                    rewritten.insert(n.clone(), handlers_path.clone());
1080                }
1081                imported_decl_paths_emit.insert(unit.clone(), rewritten);
1082            }
1083        }
1084    }
1085
1086    EmitUnitCtx {
1087        imported_methods,
1088        imported_decl_paths_emit,
1089        exports_for_consumed,
1090        file_decl_index: info.file_index.clone(),
1091    }
1092}
1093
1094#[allow(clippy::too_many_arguments)]
1095fn emit_unit(
1096    name: &str,
1097    kind: UnitKind,
1098    pf: &ParsedFile,
1099    unit_ctx: &EmitUnitCtx,
1100    history_target_agents: &HashSet<String>,
1101    unit_info: &BTreeMap<String, UnitInfo>,
1102    imported_from: &HashMap<String, String>,
1103    imported_from_kind: &HashMap<String, UnitKind>,
1104    owning_context_for_emit: &Option<String>,
1105    cross_context_for_file: &resolver::CrossContextInfo,
1106    program: &checker::CheckedProgram,
1107    target: BuildTarget,
1108    import_ext: ImportExt,
1109    contracts: bool,
1110    agent_deps_plan: Option<&AgentDepsPlan>,
1111    compiled: &mut Vec<CompiledFile>,
1112    schema_effective_versions: &HashMap<String, i64>,
1113) {
1114    let typed = program.program();
1115    // Build the emitter context.
1116    let info = &unit_info[name];
1117    let cross_context_info = cross_context_for_file.clone();
1118
1119    // v0.8: in workers mode, a context's *output* lands under
1120    // workers/<dashes>/handlers.ts. Use that path as the synthetic
1121    // source_path so the emitter's depth/relative-path logic and
1122    // imported_decl_paths produce correct relative imports.
1123    let workers_mode = matches!(target, BuildTarget::Workers);
1124    let emit_source_path = if workers_mode && kind == UnitKind::Context {
1125        worker_handlers_source_path(name)
1126    } else {
1127        pf.source_path()
1128    };
1129
1130    // message-bundles slice 1 (#859): a `messages` block's generated `render`
1131    // needs `bynk.locale`'s own `render` in scope for its fallback rung, but
1132    // under a private alias — this file's own `export function render` would
1133    // otherwise collide with a plain `import { render }`. Injected as a hand-
1134    // written extra import line rather than through the usual reference-
1135    // collection path (`collect_external_references`/`record_name_ref`),
1136    // which has no per-name aliasing of its own and would emit a colliding,
1137    // unaliased `render`.
1138    let mut extra_import_lines: Vec<String> = agent_deps_plan
1139        .map(|p| p.imports.clone())
1140        .unwrap_or_default();
1141    if pf
1142        .items()
1143        .iter()
1144        .any(|it| matches!(it, CommonsItem::Messages(_)))
1145    {
1146        let render_path = unit_ctx
1147            .imported_decl_paths_emit
1148            .get("bynk.locale")
1149            .and_then(|m| m.get("render"))
1150            .cloned()
1151            .unwrap_or_else(|| EmitProjectCtx::commons_path("bynk.locale"));
1152        let import = emitter::cross_commons_import_specifier_for_path(
1153            &emit_source_path,
1154            &render_path,
1155            import_ext,
1156        );
1157        extra_import_lines.push(format!(
1158            "import {{ render as __bynkLocaleRender, renderArg }} from \"{import}\";"
1159        ));
1160    }
1161
1162    let emit_ctx = EmitProjectCtx {
1163        source_path: emit_source_path,
1164        commons_name: name.to_string(),
1165        file_decl_index: unit_ctx.file_decl_index.clone(),
1166        imported_from: imported_from.clone(),
1167        imported_from_kind: imported_from_kind.clone(),
1168        imported_decl_paths: unit_ctx.imported_decl_paths_emit.clone(),
1169        unit_kind: kind,
1170        owning_context: owning_context_for_emit.clone(),
1171        exports_for_consumed: unit_ctx.exports_for_consumed.clone(),
1172        imported_methods: unit_ctx.imported_methods.clone(),
1173        cross_context: cross_context_info,
1174        target,
1175        local_agents: info.table.agents.keys().cloned().collect(),
1176        agent_given_deps: agent_deps_plan.map(|p| p.exprs.clone()).unwrap_or_default(),
1177        extra_import_lines,
1178        agent_method_givens: info
1179            .table
1180            .agents
1181            .iter()
1182            .map(|(agent, a)| {
1183                (
1184                    agent.clone(),
1185                    a.handlers
1186                        .iter()
1187                        .filter_map(|h| {
1188                            h.method_name
1189                                .as_ref()
1190                                .map(|m| (m.name.clone(), lower_handler_given_ir(h)))
1191                        })
1192                        .collect(),
1193                )
1194            })
1195            .collect(),
1196        // v0.47: the context's actors (merged across files), so the Bearer
1197        // verification seam resolves even when the actor and handler are in
1198        // different files of the same context.
1199        actors: info.table.actors.clone(),
1200        // Events slice 3b (#978), verified by slice 3c (#980): resolved once
1201        // per unit, merged across files the same way `actors` is above. The
1202        // registry's reconciled version wins when present (it is the
1203        // auto-bumped or `@schema(N)`-verified truth); `decl.schema_version()`
1204        // is the fallback for when the registry is off, matching every
1205        // event's pre-3c behaviour exactly.
1206        event_schema_versions: info
1207            .table
1208            .events
1209            .iter()
1210            .map(|(event_name, decl)| {
1211                let key = format!("{name}.{event_name}");
1212                let version = schema_effective_versions
1213                    .get(&key)
1214                    .copied()
1215                    .unwrap_or_else(|| decl.schema_version());
1216                (event_name.clone(), version)
1217            })
1218            .collect(),
1219        consumed_adapters: info
1220            .consumes
1221            .iter()
1222            .filter(|t| unit_info.get(*t).map(|i| i.kind) == Some(UnitKind::Adapter))
1223            .cloned()
1224            .collect(),
1225        import_ext,
1226        contracts,
1227        history_target_agents: history_target_agents.clone(),
1228        runtime_use: Default::default(),
1229    };
1230    // v0.72: the map's `source` is the absolute path the compiler read the file
1231    // from, so an editor breakpoint set on the real `.bynk` resolves to the same
1232    // path the debugger loads (project-relative would resolve against the output
1233    // `.ts`'s directory — the wrong place). Synthetic units fall back to relative.
1234    let source_name = pf.map_source_name();
1235    let (ts, source_map) = emitter::emit_project(program, &emit_ctx, pf.source(), &source_name);
1236    // Slice 3: the handler-label sidecar for this unit (ADR 0105) — names stack
1237    // frames by their Bynk operation. `None` for units with no handlers.
1238    let debug_metadata = emitter::collect_handler_labels(typed);
1239    let output_path = if workers_mode && kind == UnitKind::Context {
1240        worker_handlers_output_path(name)
1241    } else {
1242        ts_output_path(&pf.source_path())
1243    };
1244    compiled.push(CompiledFile {
1245        source_path: pf.source_path(),
1246        output_path,
1247        typescript: ts,
1248        source_map,
1249        debug_metadata,
1250    });
1251}
1252
1253/// Phase 8d/8e: resolve + check (and, in build mode, emit) every source file in
1254/// one production unit. The per-file `continue`s stay internal to this loop, so
1255/// a file that fails resolution/checking is skipped without abandoning the unit.
1256///
1257/// P4.1 (#1115): the resolve+check+context-checks core — identical for both
1258/// `Mode`s except for the four `record_analyse_types` call sites — moved to
1259/// `bynk_check::check_pipeline::check_file_core` (see that module's own doc
1260/// comment), used by both this function and the new `bynk-check`-native
1261/// analysis entry point. This function now owns only: the per-unit
1262/// `EmitUnitCtx`/`prepare_unit_check_ctx` prelude, the `Mode`-conditional
1263/// exit (`Mode::Analyse` records and stops; `Mode::Build` proceeds to
1264/// `certify`+`emit_unit`) and the emission tail itself.
1265#[allow(clippy::too_many_arguments)]
1266#[allow(clippy::type_complexity)]
1267fn check_unit_files(
1268    name: &str,
1269    kind: UnitKind,
1270    indices: &[usize],
1271    parsed: &[ParsedFile],
1272    unit_info: &BTreeMap<String, UnitInfo>,
1273    combined_types: &HashMap<String, Arc<TypeDecl>>,
1274    combined_fns: &HashMap<String, Arc<FnDecl>>,
1275    combined_methods: &HashMap<String, ResolverMethodTable>,
1276    local_names: &HashSet<String>,
1277    local_methods_for_type: &HashMap<String, Vec<FnDecl>>,
1278    consumed_types: &HashMap<String, ConsumedType>,
1279    imported_from: &HashMap<String, String>,
1280    imported_from_kind: &HashMap<String, UnitKind>,
1281    owning_context_for_emit: &Option<String>,
1282    target: BuildTarget,
1283    import_ext: ImportExt,
1284    contracts: bool,
1285    agent_deps_plan: Option<&AgentDepsPlan>,
1286    history_target_agents: &HashSet<String>,
1287    mode: Mode,
1288    errors: &mut ErrorSink,
1289    refs: &mut RefSink,
1290    hints: &mut HintSink,
1291    locals: &mut LocalsSink,
1292    exprs: &mut ExprTypeSink,
1293    requirements: &mut RequirementSink,
1294    compiled: &mut Vec<CompiledFile>,
1295    // Events track, slice 3c (#980): each locally-declared event's *effective*
1296    // schema version, keyed `<unit>.<EventName>` — the schema registry's
1297    // reconciled value when the registry is on, empty (so every lookup falls
1298    // through to `EventDecl::schema_version()`) when it is off.
1299    schema_effective_versions: &HashMap<String, i64>,
1300    tys: &Arc<Types>,
1301    // #1187's slice 6 plumbing — this unit's own accumulator; merged into
1302    // per file below, from each file's own certified `CheckedProgram`
1303    // (`RunChecks::Checked::unit_callees`'s own doc comment has the full
1304    // grounding for why this exists).
1305    unit_callees: &mut HashMap<ExprId, bynk_check::checker::Callee>,
1306) {
1307    // Emit-prologue tables invariant across every file of this unit — built
1308    // once here rather than once per file (see `EmitUnitCtx`).
1309    let unit_ctx = build_emit_unit_ctx(name, unit_info, target);
1310    let check_ctx = prepare_unit_check_ctx(kind, unit_info, combined_types, imported_from_kind);
1311
1312    for &i in indices {
1313        let pf = &parsed[i];
1314        let Some(check_pipeline::FileCheckResult {
1315            typed,
1316            cross_context: cross_context_for_file,
1317        }) = check_pipeline::check_file_core(
1318            name,
1319            kind,
1320            pf,
1321            unit_info,
1322            combined_types,
1323            combined_fns,
1324            combined_methods,
1325            local_names,
1326            local_methods_for_type,
1327            consumed_types,
1328            imported_from,
1329            &check_ctx,
1330            errors,
1331            refs,
1332            hints,
1333            locals,
1334            exprs,
1335            requirements,
1336            tys,
1337        )
1338        else {
1339            continue;
1340        };
1341
1342        // Analyse mode stops at checked: emission is build-only. Capture the
1343        // file's expression types on the way out (Ok path only — this point is
1344        // past every per-file error exit inside `check_file_core`), for
1345        // `.`-member completion.
1346        if mode == Mode::Analyse {
1347            check_pipeline::record_analyse_types(
1348                exprs,
1349                &pf.identity_path(),
1350                pf.is_synthetic(),
1351                &typed.expr_types,
1352            );
1353            continue;
1354        }
1355        // T3.7b (R3.10): every per-unit gate above already ran (check_record's
1356        // Ok path, check_context_constraints, check_context_declarations's
1357        // blocks_emission, all inside `check_file_core`) — certify makes that
1358        // structural, the same way T3.7a did for the single-file path, rather
1359        // than relying on every future call site remembering to check all
1360        // three before reaching emission. A later, unrelated diagnostic (e.g.
1361        // check_platform_lock, which runs after this whole loop) can still
1362        // bail the entire build via finish_build's separate
1363        // errors.is_empty() gate — that's whole-build atomicity (already
1364        // correct, already unconditional), orthogonal to this unit's own
1365        // certification here.
1366        let program = checker::certify(typed, Vec::new()).unwrap_or_else(|_| {
1367            panic!("bynk internal error: unit already passed every per-unit gate above")
1368        });
1369        // #1187's slice 6 plumbing: merge this file's own resolved `Callee`
1370        // classification into the unit's accumulator before `program` (and
1371        // the `TypedCommons` it wraps) is dropped at the end of this
1372        // iteration — the only point in this pipeline that ever holds it.
1373        // Filtered to the two variants either reader actually matches on
1374        // (review of #1202): every other `Callee` variant would otherwise
1375        // sit retained project-wide, for the rest of the build, to answer
1376        // two boolean-ish questions — a real `String`/`Arc` cost on a large
1377        // project with nothing reading the rest yet. Widen this filter (or
1378        // drop it) the moment a future reader needs a different variant.
1379        unit_callees.extend(program.program().callees.iter().filter_map(|(id, c)| {
1380            let keep = match c {
1381                bynk_check::checker::Callee::Cross { .. } => true,
1382                bynk_check::checker::Callee::Capability { cap, op } => {
1383                    cap == "Events" && op == "emit"
1384                }
1385                _ => false,
1386            };
1387            keep.then(|| (*id, c.clone()))
1388        }));
1389        emit_unit(
1390            name,
1391            kind,
1392            pf,
1393            &unit_ctx,
1394            history_target_agents,
1395            unit_info,
1396            imported_from,
1397            imported_from_kind,
1398            owning_context_for_emit,
1399            &cross_context_for_file,
1400            &program,
1401            target,
1402            import_ext,
1403            contracts,
1404            agent_deps_plan,
1405            compiled,
1406            schema_effective_versions,
1407        );
1408    }
1409}
1410
1411/// The outcome of the shared check pipeline (regions 1+2's shared work),
1412/// before either entry point applies its own divergent exit. The two typed
1413/// entry points (`compile_project`, `analyse_project`) project this into a
1414/// `Result<ProjectOutput, ProjectFailure>` or a `ProjectAnalysis`.
1415#[allow(clippy::large_enum_variant)]
1416enum RunChecks {
1417    /// Discovery/parse failed, or (build mode) the structural gate bailed:
1418    /// only diagnostics, no checked program. Index is not assembled here.
1419    Bailed {
1420        errors: ErrorSink,
1421        snapshots: Vec<(PathBuf, String)>,
1422        hints: HintSink,
1423        locals: LocalsSink,
1424        exprs: ExprTypeSink,
1425        requirements: RequirementSink,
1426    },
1427    /// All phases ran (per-unit checks + tests + platform-lock done).
1428    Checked {
1429        errors: ErrorSink,
1430        snapshots: Vec<(PathBuf, String)>,
1431        refs: RefSink,
1432        hints: HintSink,
1433        locals: LocalsSink,
1434        exprs: ExprTypeSink,
1435        requirements: RequirementSink,
1436        parsed: Vec<ParsedFile>,
1437        compiled: Vec<CompiledFile>,
1438        runnable_tests: Vec<RunnableTest>,
1439        integration_outputs: Vec<CompiledFile>,
1440        integration_runnables: Vec<RunnableTest>,
1441        groups: BTreeMap<String, Vec<usize>>,
1442        kinds: BTreeMap<String, UnitKind>,
1443        unit_uses: HashMap<String, Vec<String>>,
1444        unit_consumes: HashMap<String, Vec<String>>,
1445        unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
1446        unit_tables: HashMap<String, UnitTable>,
1447        // #1187's slice 6 plumbing: each unit's own `Callee` classification,
1448        // merged across its files (`ExprId` is a single project-wide
1449        // counter, `project_model.rs`'s `next_expr_id`, so merging different
1450        // files' maps never collides) — checked, resolved data the pre-check
1451        // `unit_tables` above cannot carry. Exists so a later, project-wide
1452        // pass (`build_output`/`emit_composition_root`) can read an
1453        // already-resolved `Callee::Capability`/`Callee::Cross` instead of
1454        // re-deriving the same fact by walking raw AST method-call syntax —
1455        // `check_unit_files`'s own per-file `CheckedProgram` was previously
1456        // built and dropped before any such later pass ever ran. Filtered at
1457        // merge time (`check_unit_files`'s own `unit_callees.extend` call,
1458        // review of #1202) to only `Callee::Cross` and
1459        // `Callee::Capability{cap:"Events",op:"emit"}` — the two variants
1460        // `unit_table_uses_emit`/`called_cross_context_services` actually
1461        // read today; widen the filter (or drop it) the moment a future
1462        // reader needs a different variant, rather than paying to retain
1463        // every call site's full classification project-wide for the rest
1464        // of the build on spec.
1465        unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
1466        unit_flattened: HashMap<String, HashMap<String, String>>,
1467        adapter_bindings: HashMap<String, AdapterBinding>,
1468        npm_deps: std::collections::BTreeMap<String, String>,
1469        target: BuildTarget,
1470        // Events track, slice 3c (#980): the reconciled registry document,
1471        // ready for `finish_build` to serialize onto
1472        // `ProjectOutput::schema_lock`. `None` when `schema_registry` was
1473        // `SchemaLock::Off` (#1078) — nothing for a caller to persist.
1474        schema_registry: Option<schema_registry::SchemaRegistry>,
1475    },
1476}
1477
1478#[allow(clippy::too_many_arguments)]
1479fn run_checks(
1480    // R3.9 (#1113): one `(root, prefix)` pair per `Roots::trees` entry, not a
1481    // hardcoded primary/secondary pair — every `include` tree is walked, not
1482    // just the first one or two.
1483    trees: &[(PathBuf, PathBuf)],
1484    target: BuildTarget,
1485    platform: Platform,
1486    import_ext: ImportExt,
1487    mode: Mode,
1488    overlay: &HashMap<PathBuf, String>,
1489    // v0.113: absolute subtrees to skip during discovery (author `exclude` plus
1490    // the tool's `out`/`node_modules` caches). Empty for in-memory builds.
1491    excludes: &[PathBuf],
1492    // v0.108 (in-browser track, slice 3): when `Some`, the source files are
1493    // supplied directly, one file list per `trees` entry — and filesystem
1494    // discovery is skipped. The wasm/REPL entry feeds an in-memory
1495    // single-module project this way (the source itself rides in `overlay`);
1496    // `None` keeps the on-disk discovery walk for the CLI and the LSP.
1497    discovered: Option<Vec<Vec<PathBuf>>>,
1498    // v0.115: emit the function-contract call-site guard (dev/test profile).
1499    contracts: bool,
1500    // Events track, slice 3c (#980): `On` turns on `bynk.schema.lock`
1501    // reconciliation, with its pre-read content; `Off` (every in-memory/
1502    // test/LSP caller) skips it entirely. See `CompileOptions::schema_registry`
1503    // and `SchemaLock`. #1078: no disk access here — the caller pre-reads.
1504    schema_registry: &SchemaLock,
1505    // #1085 review: only for `schema_registry::parse`'s corruption message —
1506    // naming *which* project's lock file is corrupt, now that #1078 made
1507    // `bynk-emit` disk-free (and so path-blind) for this file.
1508    project_root: &Path,
1509    tys: &Arc<Types>,
1510) -> RunChecks {
1511    let mut errors = ErrorSink::new();
1512    // v0.25 (ADR 0053): binding edges, recorded at the resolution sites and
1513    // assembled into the project index at the analyse exit.
1514    let mut refs = RefSink::new();
1515    // v0.27 (ADR 0056): inferred-type inlay hints, recorded at the checker's
1516    // binding sites. A sink (not part of the checker's Ok payload) so hints
1517    // survive the per-file error-`continue`s.
1518    let mut hints = HintSink::new();
1519    let mut locals = LocalsSink::new();
1520    // v0.99: the capability-requirement ledger — recorded at the checker's
1521    // capability-consuming sites, drained at the analyse exit for the LSP.
1522    let mut requirements = RequirementSink::new();
1523    // v0.30.2 (ADR 0063): per-file expression types, captured on the Ok path so
1524    // `.`-member completion can type a receiver. Carried like `hints`.
1525    let mut exprs = ExprTypeSink::new();
1526    let mut snapshots: Vec<(PathBuf, String)> = Vec::new();
1527
1528    // -- 1. Discovery (skipped when sources are supplied in memory). --
1529    let file_lists = match discovered {
1530        Some(files) => files,
1531        None => match project_model::phase_discovery(trees, excludes, &mut errors) {
1532            Ok(files) => files,
1533            Err(()) => {
1534                return RunChecks::Bailed {
1535                    errors,
1536                    snapshots,
1537                    hints,
1538                    locals,
1539                    exprs,
1540                    requirements,
1541                };
1542            }
1543        },
1544    };
1545    // #1077/#1081 review: `no_sources`/file-directory-conflict checks run on
1546    // every tree's file list regardless of provenance — see
1547    // `check_discovered_files`'s own doc.
1548    if project_model::check_discovered_files(trees, &file_lists, &mut errors).is_err() {
1549        return RunChecks::Bailed {
1550            errors,
1551            snapshots,
1552            hints,
1553            locals,
1554            exprs,
1555            requirements,
1556        };
1557    }
1558
1559    // -- 2. Parse every file. --
1560    let (mut parsed, consumes_bynk, consumes_cloudflare) = match project_model::phase_parse(
1561        trees,
1562        &file_lists,
1563        overlay,
1564        &mut errors,
1565        &mut snapshots,
1566    ) {
1567        Ok(out) => out,
1568        Err(()) => {
1569            return RunChecks::Bailed {
1570                errors,
1571                snapshots,
1572                hints,
1573                locals,
1574                exprs,
1575                requirements,
1576            };
1577        }
1578    };
1579
1580    // -- 2b. Normalize service-level `by`/`given` defaults (v0.155). A service
1581    //        header default is injected into every handler that omits its own
1582    //        clause, so every downstream phase (grouping, checking, validation,
1583    //        emission) reads canonical handlers with no special-casing. `parsed`
1584    //        is indexed (not cloned) by later phases, so mutating it here reaches
1585    //        them all. The parsed AST that `bynk fmt` produces is untouched (it
1586    //        parses independently), so the terse inheriting source round-trips.
1587    project_model::normalize_service_defaults(&mut parsed);
1588    let parsed = parsed;
1589
1590    // -- 3. Group by (name, kind) and validate per-directory consistency.
1591    //       P5.2 (`design/tracks/semantics-in-the-checker.md` §6):
1592    //       `phase_group` now also confines function types to non-boundary
1593    //       positions directly, at the point its old optional hook used to
1594    //       fire — see that function's own doc comment. --
1595    let (groups, kinds, test_groups, integration_groups, adapter_bindings, npm_deps) =
1596        project_model::phase_group(
1597            &parsed,
1598            trees,
1599            platform,
1600            consumes_bynk,
1601            consumes_cloudflare,
1602            overlay,
1603            &mut errors,
1604        );
1605
1606    // -- 4. Build per-unit combined symbol tables. --
1607    let unit_tables = project_model::phase_symbol_tables(&groups, &kinds, &parsed, &mut errors);
1608
1609    // -- 5. Resolve `uses` clauses (target must exist + be a commons). --
1610    let unit_uses =
1611        project_model::phase_resolve_uses(&groups, &kinds, &parsed, &unit_tables, &mut errors);
1612
1613    // -- 5b. Resolve `consumes` clauses (target must exist + be a context). --
1614    let (unit_consumes, unit_flattened) = project_model::phase_resolve_consumes(
1615        &groups,
1616        &kinds,
1617        &parsed,
1618        &unit_tables,
1619        &mut errors,
1620        &mut refs,
1621    );
1622
1623    // -- 5b'. Collect `consumes` aliases (v0.6 §3.1). Each consuming context
1624    //         has an alias map: alias → consumed-context qualified name.
1625    //         Detect alias-alias conflicts here; alias-vs-local-decl conflicts
1626    //         are checked once the local symbol tables are built (step 6+).
1627    let unit_consumes_aliases =
1628        project_model::phase_consumes_aliases(&groups, &kinds, &parsed, &unit_tables, &mut errors);
1629
1630    // -- 5b''. v0.173 (ADR 0196 D1): warn where a `bynk.Secrets` read names its
1631    //          secret with a computed expression. P5.5
1632    //          (`design/tracks/semantics-in-the-checker.md` §6, §9): relocated
1633    //          to `bynk_check::project_model::phase_secrets_computed_name` —
1634    //          this is now a caller, not an owner, the same move as this
1635    //          function's neighbours above. See that function's own doc for
1636    //          why raising it here no longer reaches the editor by itself.
1637    project_model::phase_secrets_computed_name(
1638        target,
1639        &parsed,
1640        &groups,
1641        &kinds,
1642        &unit_flattened,
1643        &mut errors,
1644    );
1645
1646    // -- 5c. Detect `consumes` cycles. --
1647    project_model::phase_detect_consumes_cycles(&groups, &parsed, &unit_consumes, &mut errors);
1648
1649    // -- 6. Name-conflict detection for uses imports (commons-only check). --
1650    project_model::phase_uses_name_conflicts(
1651        &unit_uses,
1652        &unit_tables,
1653        &parsed,
1654        &groups,
1655        &mut errors,
1656    );
1657
1658    // -- 6a'. message-bundles slice 1 (#859): messages-block legality,
1659    //         @reference cardinality, within-block duplicate codes, and the
1660    //         `uses bynk.locale` dependency. Runs here (not in phase_group)
1661    //         because it needs `unit_uses`, resolved just above.
1662    //
1663    //         P5.0 (#1128, `design/tracks/semantics-in-the-checker.md` §6):
1664    //         relocated to `bynk-check::project_model` alongside the rest of
1665    //         this pipeline (P4.1's own move) — this is now a caller, not an
1666    //         owner, the same way P4.0/P4.1 turned this function into a
1667    //         caller of `bynk-project`/`bynk-check`.
1668    project_model::phase_messages_bundles(&parsed, &groups, &kinds, &unit_uses, &mut errors);
1669
1670    // -- 6a''. Locale capability track, slice 2 (#882): a context reaching
1671    //          two or more message-bundle commons while consuming `Locale`
1672    //          has no single bundle to negotiate against. P5.0: relocated,
1673    //          see above.
1674    project_model::phase_locale_bundle_ambiguity(
1675        &parsed,
1676        &groups,
1677        &kinds,
1678        &unit_uses,
1679        &unit_flattened,
1680        &mut errors,
1681    );
1682
1683    // -- 6a'''. Events track, slice 0 (spine #936): a `from Events(E)`
1684    //           subscription must name a real, declared event — needs
1685    //           `unit_tables` + `unit_consumes` together, so it runs here
1686    //           rather than in the per-context `check_service_protocols`.
1687    //
1688    //           P5.1 (#1130, `design/tracks/semantics-in-the-checker.md` §6):
1689    //           relocated to `bynk-check::project_model`, same move as
1690    //           P5.0's neighbours above.
1691    project_model::phase_event_subscriptions(
1692        &parsed,
1693        &groups,
1694        &kinds,
1695        &unit_tables,
1696        &unit_consumes,
1697        &unit_uses,
1698        &mut errors,
1699    );
1700
1701    // -- 6b. Validate exports clauses (each name is a locally-declared type;
1702    //         no duplicates within or across opaque/transparent). --
1703    let exports_visibility = project_model::phase_validate_type_exports(
1704        &groups,
1705        &kinds,
1706        &parsed,
1707        &unit_tables,
1708        &mut errors,
1709        &mut refs,
1710    );
1711
1712    // -- 6b'. Validate `exports capability { … }` clauses (v0.15 §4.1): each
1713    //          name must be a capability the context declares *and* provides. --
1714    project_model::phase_validate_capability_exports(
1715        &groups,
1716        &kinds,
1717        &parsed,
1718        &unit_tables,
1719        &mut errors,
1720        &mut refs,
1721    );
1722
1723    // -- 6c. Validate that providers match their capabilities exactly. --
1724    project_model::phase_validate_providers(&unit_tables, &groups, &parsed, &mut errors, tys);
1725
1726    // -- 6d. Events track, slice 3c (#980): reconcile every event's shape
1727    //        against the committed schema registry. `schema_registry` is
1728    //        `SchemaLock::Off` for every in-memory/test/LSP/fixture caller
1729    //        (opt-in — see `CompileOptions::schema_registry`'s doc), in which
1730    //        case this is a no-op and every event falls back to today's
1731    //        `@schema(N)`-or-`1` behaviour. Must run before the per-unit loop
1732    //        below: `emit_unit` needs `schema_effective_versions` to mint the
1733    //        right `schemaVersion`, and by the time `RunChecks` reaches
1734    //        `finish_build` the TypeScript is already emitted. Only the
1735    //        *write* is deferred — to the caller, gated on a fully clean
1736    //        build (#1078: `bynk-emit` computes, never writes) — reconciliation
1737    //        itself happens here. P5.3: `reconcile` itself now lives in
1738    //        `bynk_check::schema_registry` (this crate is a caller, not an
1739    //        owner) — `SchemaRegistry` stays re-exported from this crate's
1740    //        own `schema_registry` module. P5.5: the corrupt-file diagnostic
1741    //        moved too — `bynk_check::schema_registry::parse_or_diagnose` is
1742    //        now this crate's caller-side of both the parse and the
1743    //        `bynk.project.schema_registry_corrupt` construction (§3.2's
1744    //        "eighth site").
1745    let mut schema_effective_versions: HashMap<String, i64> = HashMap::new();
1746    let mut schema_registry_doc: Option<schema_registry::SchemaRegistry> = None;
1747    if let SchemaLock::On { existing } = schema_registry {
1748        match bynk_check::schema_registry::parse_or_diagnose(existing.as_deref(), project_root) {
1749            Ok(existing_reg) => {
1750                let mut schema_errors: Vec<CompileError> = Vec::new();
1751                let (updated, effective) = bynk_check::schema_registry::reconcile(
1752                    &existing_reg,
1753                    &unit_tables,
1754                    &mut schema_errors,
1755                );
1756                errors.extend_for(None, schema_errors);
1757                schema_effective_versions = effective;
1758                schema_registry_doc = Some(updated);
1759            }
1760            Err(err) => {
1761                errors.push_for(None, err);
1762            }
1763        }
1764    }
1765
1766    if !errors.is_empty() && mode == Mode::Build {
1767        return RunChecks::Bailed {
1768            errors,
1769            snapshots,
1770            hints,
1771            locals,
1772            exprs,
1773            requirements,
1774        };
1775    }
1776
1777    // -- 7. Build per-unit file index (which file declares which name). --
1778    let unit_file_index = project_model::phase_file_index(&groups, &parsed);
1779
1780    // -- 7b (v0.29.4). Assemble the nine parallel per-unit maps into one record
1781    //          per unit. Driven by the `groups` keyset (the authority), so every
1782    //          group yields exactly one `UnitInfo` with all facets present. The
1783    //          producer maps are cloned, not moved, because the back half of the
1784    //          pipeline (tests, integration tests, platform-lock, composition
1785    //          root, the workers branch) still reads the originals.
1786    let unit_info = project_model::assemble_unit_info(
1787        &groups,
1788        &kinds,
1789        &unit_tables,
1790        &unit_uses,
1791        &unit_consumes,
1792        &unit_flattened,
1793        &unit_consumes_aliases,
1794        &exports_visibility,
1795        &unit_file_index,
1796    );
1797
1798    // -- 8. For each unit, build the combined symbol space and run
1799    //       resolve+check per source file. --
1800    let mut compiled: Vec<CompiledFile> = Vec::new();
1801    // #1187's slice 6 plumbing (see `RunChecks::Checked::unit_callees`'s own
1802    // doc comment) — one `Callee` map per unit, merged across that unit's
1803    // own files inside the loop below.
1804    let mut unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>> =
1805        HashMap::new();
1806
1807    // v0.119 (testing track slice 7, ADR 0155): a project-wide fold over every
1808    // parsed file, producing the identical `HashSet` regardless of which unit
1809    // or file is currently emitting — computed once here rather than once per
1810    // emitted file (`collect_history_target_agents` used to be called from
1811    // inside the per-file emit prologue).
1812    let history_target_agents = collect_history_target_agents(&parsed);
1813
1814    for (name, info) in &unit_info {
1815        let kind = info.kind;
1816        let indices = info.files.as_slice();
1817        let local_table = &info.table;
1818        // v0.24: skip resolve/check only when THIS group's composition
1819        // failed. In build mode the sink is empty here (the structural gate
1820        // bailed), so the delta equals the old global is_empty check; in
1821        // analyse mode one broken unit no longer suppresses every other
1822        // unit's semantic diagnostics.
1823        let group_error_baseline = errors.len();
1824
1825        let (
1826            mut combined_types,
1827            combined_fns,
1828            mut combined_methods,
1829            mut imported_from,
1830            mut imported_from_kind,
1831        ) = project_model::compose_unit_symbols(name, local_table, &unit_info);
1832        let consumed_types = project_model::merge_consumed_exports(
1833            name,
1834            &parsed,
1835            &unit_info,
1836            &mut combined_types,
1837            &mut combined_methods,
1838            &mut imported_from,
1839            &mut imported_from_kind,
1840            &mut errors,
1841        );
1842
1843        if errors.len() > group_error_baseline {
1844            continue;
1845        }
1846
1847        let local_names: HashSet<String> = local_table.types.keys().cloned().collect();
1848
1849        let local_methods_for_type = project_model::collect_unit_methods(indices, &parsed);
1850
1851        // Per-context view information for the emitter and checker.
1852        let owning_context_for_emit = if kind == UnitKind::Context {
1853            Some(name.clone())
1854        } else {
1855            None
1856        };
1857
1858        // #527: workers contexts get a DO-side deps plan for their agents'
1859        // `given` capabilities (the wire cannot carry providers).
1860        let agent_deps_plan = if matches!(target, BuildTarget::Workers) && kind == UnitKind::Context
1861        {
1862            plan_agent_given_deps(name, &unit_info, &adapter_bindings)
1863        } else {
1864            None
1865        };
1866
1867        check_unit_files(
1868            name,
1869            kind,
1870            indices,
1871            &parsed,
1872            &unit_info,
1873            &combined_types,
1874            &combined_fns,
1875            &combined_methods,
1876            &local_names,
1877            &local_methods_for_type,
1878            &consumed_types,
1879            &imported_from,
1880            &imported_from_kind,
1881            &owning_context_for_emit,
1882            target,
1883            import_ext,
1884            contracts,
1885            agent_deps_plan.as_ref(),
1886            &history_target_agents,
1887            mode,
1888            &mut errors,
1889            &mut refs,
1890            &mut hints,
1891            &mut locals,
1892            &mut exprs,
1893            &mut requirements,
1894            &mut compiled,
1895            &schema_effective_versions,
1896            tys,
1897            unit_callees.entry(name.clone()).or_default(),
1898        );
1899    }
1900
1901    // v0.7: process test declarations. Each `test commerce.X` group resolves
1902    // its target, validates mocks against the target's capability/consumed-
1903    // context shapes, type-checks bodies with the target's privileged view,
1904    // and emits a per-target TypeScript test module under `tests/`.
1905    let mut test_errors: Vec<CompileError> = Vec::new();
1906    // v0.132: barrel output paths emitted so far, shared across the unit- and
1907    // integration-test passes so a multi-file commons imported by both is
1908    // aggregated into `out/<name>.ts` exactly once.
1909    let mut emitted_barrels: HashSet<PathBuf> = HashSet::new();
1910    let (test_outputs, runnable_tests) = process_tests(
1911        &test_groups,
1912        &parsed,
1913        &kinds,
1914        &unit_tables,
1915        &exports_visibility,
1916        &unit_consumes,
1917        &unit_consumes_aliases,
1918        &unit_uses,
1919        &unit_flattened,
1920        &groups,
1921        import_ext,
1922        contracts,
1923        &mut emitted_barrels,
1924        &mut test_errors,
1925        &mut refs,
1926        tys,
1927    );
1928    // #696: test-suite diagnostics do have owning files, but attributing them
1929    // means threading a file through `process_tests`'s many internal push sites —
1930    // a separable follow-up. They render in the plain `[category]` form for now.
1931    errors.extend_for(None, test_errors);
1932
1933    compiled.extend(test_outputs);
1934
1935    // v0.16: process integration tests. Each `test integration "name"` suite
1936    // validates its `wires` participants, type-checks each case body as a
1937    // cross-context call from a synthetic harness root that consumes every
1938    // participant, and emits a TypeScript module that stands the participants
1939    // up as in-process Workers and exercises the flow across the real wire.
1940    let mut integration_errors: Vec<CompileError> = Vec::new();
1941    let (integration_outputs, integration_runnables) = process_integration_tests(
1942        &integration_groups,
1943        &parsed,
1944        &kinds,
1945        &unit_tables,
1946        &unit_consumes,
1947        &unit_consumes_aliases,
1948        &unit_uses,
1949        &groups,
1950        &mut emitted_barrels,
1951        &mut integration_errors,
1952        &mut refs,
1953        tys,
1954    );
1955    // #696: integration-suite diagnostics, like the unit-test ones above, stay
1956    // unattributed pending the same `process_integration_tests` threading.
1957    errors.extend_for(None, integration_errors);
1958
1959    // v0.19 (decisions 0017/0024): platform-lock enforcement. A deployment
1960    // unit whose in-process closure reaches a platform-native capability is
1961    // locked to that platform; the selected `--platform` must match. Run only
1962    // on otherwise-clean programs: the closure walk recurses the provider
1963    // graph, whose acyclicity the earlier checks establish. P5.3: relocated
1964    // to `bynk_check::project_model::phase_platform_lock` — this crate is a
1965    // caller, not an owner.
1966    if errors.is_empty() {
1967        project_model::phase_platform_lock(
1968            target,
1969            platform,
1970            &parsed,
1971            &groups,
1972            &kinds,
1973            &unit_tables,
1974            &unit_consumes,
1975            &unit_consumes_aliases,
1976            &unit_flattened,
1977            &mut errors,
1978        );
1979    }
1980
1981    // v0.176 (#642): the `Bytes`-at-a-workers-boundary guard (ADR 0142 D8) is
1982    // retired here. It existed because the workers boundary carried its own
1983    // codec dispatch, which cast a `Bytes` to `JsonValue` on the way out while
1984    // base64-decoding it on the way in — so a `Bytes` mis-round-tripped, and a
1985    // diagnostic was better than silent corruption. That dispatch is gone: every
1986    // wire position now routes through `serialisation.rs`, whose `Bytes` arm
1987    // base64-encodes. The restriction has no remaining cause, and ADR 0142 D8's
1988    // deferral to "the roadmap's typed cross-context boundary fix" is discharged.
1989
1990    RunChecks::Checked {
1991        errors,
1992        snapshots,
1993        refs,
1994        hints,
1995        locals,
1996        exprs,
1997        requirements,
1998        parsed,
1999        compiled,
2000        runnable_tests,
2001        integration_outputs,
2002        integration_runnables,
2003        groups,
2004        kinds,
2005        unit_uses,
2006        unit_consumes,
2007        unit_consumes_aliases,
2008        unit_tables,
2009        unit_callees,
2010        unit_flattened,
2011        adapter_bindings,
2012        npm_deps,
2013        target,
2014        schema_registry: schema_registry_doc,
2015    }
2016}
2017
2018/// Build-success tail (region 3): emit the composition/worker/runtime files
2019/// and assemble the final `ProjectOutput`. Reached only on build mode with a
2020/// clean error sink. Moved verbatim from the old pipeline; only the locals it
2021/// reads are now bound from the `Checked` variant.
2022#[allow(clippy::too_many_arguments)]
2023fn build_output(
2024    parsed: Vec<ParsedFile>,
2025    mut compiled: Vec<CompiledFile>,
2026    mut runnable_tests: Vec<RunnableTest>,
2027    integration_outputs: Vec<CompiledFile>,
2028    integration_runnables: Vec<RunnableTest>,
2029    groups: BTreeMap<String, Vec<usize>>,
2030    kinds: BTreeMap<String, UnitKind>,
2031    unit_consumes: HashMap<String, Vec<String>>,
2032    unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
2033    unit_tables: HashMap<String, UnitTable>,
2034    unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
2035    // v0.177 (#643): needed to build each context's *own* combined type table,
2036    // so its contract hashes are computed from the same namespace a caller sees.
2037    unit_uses: HashMap<String, Vec<String>>,
2038    unit_flattened: HashMap<String, HashMap<String, String>>,
2039    adapter_bindings: HashMap<String, AdapterBinding>,
2040    npm_deps: std::collections::BTreeMap<String, String>,
2041    target: BuildTarget,
2042    import_ext: ImportExt,
2043) -> ProjectOutput {
2044    compiled.extend(integration_outputs);
2045    runnable_tests.extend(integration_runnables);
2046
2047    // v0.67: the discovery manifest — built from the combined runnable set before
2048    // anything consumes it, so `--no-run --format json` lists suites/cases without
2049    // running. Ordered by the runner's sort key to match a run's suite order.
2050    let discovered = discovery_manifest(&runnable_tests);
2051
2052    // v0.16: emit the combined top-level test runner once both passes are done,
2053    // so `tests/main.ts` aggregates unit and integration suites together.
2054    if !runnable_tests.is_empty() {
2055        let main_ts = emit_test_main(&runnable_tests, import_ext);
2056        compiled.push(CompiledFile {
2057            source_path: PathBuf::from("tests/main.test.bynk"),
2058            output_path: PathBuf::from("tests/main.ts"),
2059            typescript: main_ts,
2060            source_map: None,
2061            debug_metadata: None,
2062        });
2063    }
2064
2065    // v0.19 (decision 0025): does any context's in-process closure reach a
2066    // platform-native unit? Drives env threading (bundle) and the per-Worker
2067    // Env/`wrangler.toml` resource derivation (workers).
2068    let context_native: HashMap<String, std::collections::BTreeMap<Platform, String>> = kinds
2069        .iter()
2070        .filter(|(_, k)| **k == UnitKind::Context)
2071        .filter_map(|(name, _)| {
2072            let table = unit_tables.get(name)?;
2073            let native = native_platforms_of_context(
2074                name,
2075                table,
2076                &unit_tables,
2077                &unit_consumes,
2078                &unit_consumes_aliases,
2079                &unit_flattened,
2080            );
2081            (!native.is_empty()).then(|| (name.clone(), native))
2082        })
2083        .collect();
2084
2085    // Events track, slice 0 (spine #936): project-wide "who subscribes to
2086    // what", built once and shared by both targets — Bundle mode's
2087    // `composeApp` dispatches in-process; Workers mode uses it to size each
2088    // publishing context's fan-out DO routing table and wrangler.toml
2089    // Service Bindings.
2090    let event_subscribers = discover_event_subscribers(&unit_tables, &unit_consumes);
2091
2092    match target {
2093        BuildTarget::Bundle => {
2094            // v0.6 §6.3: emit a composition root when the project has at
2095            // least one context that consumes another context's service
2096            // surface. The compose file imports each context, instantiates
2097            // its providers, assembles its deps (capabilities + cross-
2098            // context surfaces), and exports the top-level service surface.
2099            if let Some(compose_ts) = emit_composition_root(
2100                &groups,
2101                &kinds,
2102                &unit_consumes,
2103                &unit_consumes_aliases,
2104                &unit_tables,
2105                &unit_callees,
2106                &adapter_bindings,
2107                &unit_flattened,
2108                // D1: thread `env` through composeApp only when a native
2109                // resource is consumed, so native-free programs are
2110                // byte-identical to v0.18 output.
2111                !context_native.is_empty(),
2112                &event_subscribers,
2113            ) {
2114                compiled.push(CompiledFile {
2115                    source_path: PathBuf::from("compose.bynk"),
2116                    output_path: PathBuf::from("compose.ts"),
2117                    typescript: compose_ts,
2118                    source_map: None,
2119                    debug_metadata: None,
2120                });
2121            }
2122        }
2123        BuildTarget::Workers => {
2124            // v0.8 §2.3: per-Worker entry point, compose.ts, and wrangler
2125            // configuration. One Worker per context.
2126            for (ctx_name, kind) in &kinds {
2127                if *kind != UnitKind::Context {
2128                    continue;
2129                }
2130                let Some(table) = unit_tables.get(ctx_name) else {
2131                    continue;
2132                };
2133                let dashes = worker_dir_name(ctx_name);
2134                let consumes_targets = unit_consumes.get(ctx_name).cloned().unwrap_or_default();
2135                let aliases = unit_consumes_aliases
2136                    .get(ctx_name)
2137                    .cloned()
2138                    .unwrap_or_default();
2139                // v0.177 (#643): the callee's own view of each of its `on call`
2140                // contracts, hashed from *its own* namespace — the same table
2141                // (`combined_types_for`) a caller reaches through
2142                // `consumed_types[ctx_name]`, so the two sides cannot disagree.
2143                let own_types =
2144                    bynk_check::symbols::combined_types_for(ctx_name, &unit_tables, &unit_uses);
2145                let own_contracts = own_contract_hashes(table, &own_types);
2146                let binding_modules: HashMap<String, String> = adapter_bindings
2147                    .iter()
2148                    .map(|(n, b)| {
2149                        (
2150                            n.clone(),
2151                            emitter::ts_specifier(&b.output_path.with_extension("js")),
2152                        )
2153                    })
2154                    .collect();
2155                let flattened = unit_flattened.get(ctx_name).cloned().unwrap_or_default();
2156                // v0.19 (C1): this Worker needs the KV namespace binding when
2157                // its in-process closure reaches the cloudflare adapter.
2158                let needs_kv = context_native
2159                    .get(ctx_name)
2160                    .is_some_and(|n| n.values().any(|u| u == firstparty::CLOUDFLARE_UNIT));
2161                // Locale capability track, slice 2 (#882, Decision A): real
2162                // negotiation is Cloudflare-only — `BuildTarget::Workers`
2163                // isn't itself restricted to `Platform::Cloudflare`, so a
2164                // hypothetical `--target workers --platform node` project
2165                // must not try to pass 3 args to Node's still-0-arg
2166                // `LocaleProvider`.
2167                let is_cloudflare_binding = adapter_bindings
2168                    .get(firstparty::BYNK_UNIT)
2169                    .is_some_and(|b| {
2170                        b.output_path.file_name()
2171                            == Some(std::ffi::OsStr::new(
2172                                firstparty::Platform::Cloudflare.bynk_binding_filename(),
2173                            ))
2174                    });
2175                let bundle = bynk_check::symbols::detect_context_message_bundle(
2176                    ctx_name, &unit_uses, &groups, &kinds, &parsed,
2177                );
2178                let locale_bundle_info = match &bundle {
2179                    bynk_check::symbols::ContextMessageBundle::One(info)
2180                        if is_cloudflare_binding =>
2181                    {
2182                        Some(info)
2183                    }
2184                    _ => None,
2185                };
2186                // #1187's slice 6 plumbing: computed once, reused by every
2187                // Workers-target emitter below that needs it.
2188                let ctx_uses_emit = unit_table_uses_emit(table, unit_callees.get(ctx_name));
2189                let (compose_ts, needs_locale_request) = emitter::emit_worker_compose(
2190                    ctx_name,
2191                    table,
2192                    &consumes_targets,
2193                    &aliases,
2194                    &unit_tables,
2195                    &binding_modules,
2196                    &flattened,
2197                    &unit_consumes,
2198                    &unit_consumes_aliases,
2199                    &unit_flattened,
2200                    needs_kv,
2201                    locale_bundle_info,
2202                    import_ext,
2203                    ctx_uses_emit,
2204                );
2205                let entry_ts = emitter::emit_worker_entry(
2206                    ctx_name,
2207                    table,
2208                    &own_contracts,
2209                    needs_locale_request,
2210                    ctx_uses_emit,
2211                );
2212                // Adapters are not Workers, so they get no Service Binding in
2213                // the consumer's wrangler config — drop them from the list.
2214                let mut service_consumes: BTreeSet<String> = consumes_targets
2215                    .iter()
2216                    .filter(|t| !binding_modules.contains_key(*t))
2217                    .cloned()
2218                    .collect();
2219                // Events track, slice 0 (spine #936, ADR 0284): this
2220                // context's own published events → their subscribers,
2221                // sliced from the project-wide table. A subscriber
2222                // `consumes` the publisher for the event *type*; nothing
2223                // upstream gives the publisher a binding back to the
2224                // subscriber, so its Worker needs one added here — the
2225                // reverse direction of an ordinary `consumes` edge.
2226                let own_event_routes: BTreeMap<String, Vec<(String, String)>> = event_subscribers
2227                    .iter()
2228                    .filter(|((owner, _), _)| owner == ctx_name)
2229                    .map(|((_, name), subs)| (name.clone(), subs.clone()))
2230                    .collect();
2231                service_consumes.extend(
2232                    own_event_routes
2233                        .values()
2234                        .flatten()
2235                        .map(|(sub_ctx, _)| sub_ctx.clone()),
2236                );
2237                let service_consumes: Vec<String> = service_consumes.into_iter().collect();
2238                // P6.x cutover slice 2 (#1191): collected here, not inside
2239                // `emit_wrangler_toml` itself, so that function's own file
2240                // needs no `bynk_syntax::ast` match — `project.rs` already
2241                // does (this loop is the relocated match, unchanged in
2242                // substance: same `HandlerKind::Cron`/`ServiceProtocol::Queue`
2243                // shapes, same sort+dedup, just one call frame up).
2244                let mut crons: Vec<String> = Vec::new();
2245                let mut queues: Vec<String> = Vec::new();
2246                for service in table.services.values() {
2247                    for handler in &service.handlers {
2248                        if let HandlerKind::Cron { expr } = &handler.kind {
2249                            crons.push(expr.clone());
2250                        }
2251                    }
2252                    // v0.44: one queue binding per service, on the
2253                    // `from queue("name")` header.
2254                    if let ServiceProtocol::Queue { name } = &service.protocol {
2255                        queues.push(name.clone());
2256                    }
2257                }
2258                crons.sort();
2259                crons.dedup();
2260                queues.sort();
2261                queues.dedup();
2262                let wrangler = emitter::emit_wrangler_toml(
2263                    ctx_name,
2264                    table,
2265                    &service_consumes,
2266                    needs_kv,
2267                    &crons,
2268                    &queues,
2269                    ctx_uses_emit,
2270                );
2271                compiled.push(CompiledFile {
2272                    source_path: PathBuf::from(format!("workers/{dashes}/<index>")),
2273                    output_path: PathBuf::from(format!("workers/{dashes}/index.ts")),
2274                    typescript: entry_ts,
2275                    source_map: None,
2276                    debug_metadata: None,
2277                });
2278                // Events track, slice 0: this context's fan-out Durable
2279                // Object — emitted only when it actually publishes (mirrors
2280                // `emit_worker_compose`'s own `unit_table_uses_emit` gate on
2281                // `deps.__eventsDispatch`, so the two never disagree about
2282                // whether `env.EVENTS_FANOUT` is real).
2283                if ctx_uses_emit {
2284                    let fanout_ts = emitter::emit_events_fanout_do(ctx_name, &own_event_routes);
2285                    compiled.push(CompiledFile {
2286                        source_path: PathBuf::from(format!("workers/{dashes}/<events-fanout>")),
2287                        output_path: PathBuf::from(format!("workers/{dashes}/events_fanout.ts")),
2288                        typescript: fanout_ts,
2289                        source_map: None,
2290                        debug_metadata: None,
2291                    });
2292                }
2293                compiled.push(CompiledFile {
2294                    source_path: PathBuf::from(format!("workers/{dashes}/<compose>")),
2295                    output_path: PathBuf::from(format!("workers/{dashes}/compose.ts")),
2296                    typescript: compose_ts,
2297                    source_map: None,
2298                    debug_metadata: None,
2299                });
2300                compiled.push(CompiledFile {
2301                    source_path: PathBuf::from(format!("workers/{dashes}/<wrangler>")),
2302                    output_path: PathBuf::from(format!("workers/{dashes}/wrangler.toml")),
2303                    typescript: wrangler,
2304                    source_map: None,
2305                    debug_metadata: None,
2306                });
2307                // v0.172 (ADR 0195 D5): the secret names this Worker's handlers
2308                // will read from `env`, for `deploy` to check before it pushes.
2309                // Emitted from the same seams the entry lowers, so the two
2310                // cannot describe different Workers.
2311                //
2312                // v0.173 (ADR 0196): plus the literal `bynk.Secrets` names it
2313                // reads, and whether that list is everything. The walk is here
2314                // rather than in the checker because it needs `unit_flattened`
2315                // to answer *whose* `Secrets` this is (D4) and the warning sink
2316                // to say when it cannot know a name — and `bynk-check` has
2317                // neither. Absent when there is nothing at all to say (D5).
2318                // The warnings half is dropped here: `run_checks` already raised
2319                // it, on the analyse path the editor shares.
2320                // v0.177 (#643): the contract hashes this context's entry
2321                // enforces, written where `deploy` can read them — so a skew is
2322                // refused at the push rather than discovered by live traffic.
2323                let own_types =
2324                    bynk_check::symbols::combined_types_for(ctx_name, &unit_tables, &unit_uses);
2325                // What this context expects of each dependency — the same hash
2326                // it stamps at each call site, computed the same way: in the
2327                // *dependency's* namespace, from the dependency's own table.
2328                //
2329                // Only the services this context actually **calls**. Recording
2330                // everything the dependency provides would refuse a deploy over a
2331                // service this caller never touches — a skew its runtime check
2332                // could never fire on. See `called_cross_context_services`.
2333                let called = called_cross_context_services(
2334                    table,
2335                    unit_consumes
2336                        .get(ctx_name)
2337                        .map(Vec::as_slice)
2338                        .unwrap_or(&[]),
2339                    unit_callees.get(ctx_name),
2340                );
2341                let mut expects: std::collections::BTreeMap<
2342                    String,
2343                    std::collections::BTreeMap<String, String>,
2344                > = std::collections::BTreeMap::new();
2345                for (dep, services) in &called {
2346                    let Some(dep_table) = unit_tables.get(dep) else {
2347                        continue;
2348                    };
2349                    let dep_types =
2350                        bynk_check::symbols::combined_types_for(dep, &unit_tables, &unit_uses);
2351                    let all = own_contract_hashes(dep_table, &dep_types);
2352                    let hashes: std::collections::BTreeMap<String, String> = all
2353                        .into_iter()
2354                        .filter(|(svc, _)| services.contains(svc))
2355                        .collect();
2356                    if !hashes.is_empty() {
2357                        expects.insert(dep.clone(), hashes);
2358                    }
2359                }
2360                if let Some(manifest) = emitter::contracts::emit_contracts_manifest(
2361                    &own_contract_hashes(table, &own_types),
2362                    &expects,
2363                ) {
2364                    compiled.push(CompiledFile {
2365                        source_path: PathBuf::from(format!("workers/{dashes}/<contracts>")),
2366                        output_path: PathBuf::from(format!(
2367                            "workers/{dashes}/{}",
2368                            emitter::contracts::CONTRACTS_MANIFEST
2369                        )),
2370                        typescript: manifest,
2371                        source_map: None,
2372                        debug_metadata: None,
2373                    });
2374                }
2375
2376                let (reads, _) = emitter::secrets::secret_reads(table, &flattened);
2377                if let Some(manifest) = emitter::emit_secrets_manifest(table, &reads) {
2378                    compiled.push(CompiledFile {
2379                        source_path: PathBuf::from(format!("workers/{dashes}/<secrets>")),
2380                        output_path: PathBuf::from(format!(
2381                            "workers/{dashes}/{}",
2382                            emitter::secrets::SECRETS_MANIFEST
2383                        )),
2384                        typescript: manifest,
2385                        source_map: None,
2386                        debug_metadata: None,
2387                    });
2388                }
2389            }
2390        }
2391    }
2392
2393    // v0.17: copy each adapter binding verbatim into the output, beside the
2394    // adapter's emitted interface module, so compose's import resolves and the
2395    // `tsc` gate checks the `implements` contract.
2396    let mut binding_names: Vec<&String> = adapter_bindings.keys().collect();
2397    binding_names.sort();
2398    for name in binding_names {
2399        let b = &adapter_bindings[name];
2400        compiled.push(CompiledFile {
2401            source_path: b.output_path.clone(),
2402            output_path: b.output_path.clone(),
2403            typescript: b.content.clone(),
2404            source_map: None,
2405            debug_metadata: None,
2406        });
2407    }
2408
2409    // v0.17: emit `package.json` only when an adapter declares npm deps, so
2410    // existing (adapter-free) projects are unchanged.
2411    if !npm_deps.is_empty() {
2412        compiled.push(CompiledFile {
2413            source_path: PathBuf::from("<package.json>"),
2414            output_path: PathBuf::from("package.json"),
2415            typescript: render_package_json(&npm_deps),
2416            source_map: None,
2417            debug_metadata: None,
2418        });
2419    }
2420
2421    // Runtime + tsconfig: emit once per project. The runtime sits at the
2422    // root of `out/` so every emitted file's `runtime.js` import resolves
2423    // relative to it. `tsconfig.json` is also at the root so `tsc -p out/
2424    // tsconfig.json` discovers every `.ts` file in the tree.
2425    compiled.push(CompiledFile {
2426        source_path: PathBuf::from("<runtime>"),
2427        output_path: PathBuf::from("runtime.ts"),
2428        typescript: emitter::emit_runtime_module(),
2429        source_map: None,
2430        debug_metadata: None,
2431    });
2432    compiled.push(CompiledFile {
2433        source_path: PathBuf::from("<tsconfig>"),
2434        output_path: PathBuf::from("tsconfig.json"),
2435        typescript: emitter::emit_tsconfig(),
2436        source_map: None,
2437        debug_metadata: None,
2438    });
2439
2440    compiled.sort_by(|a, b| a.source_path.cmp(&b.source_path));
2441    ProjectOutput {
2442        files: compiled,
2443        discovered,
2444        // Populated by `compile_project` from the run's warning sink (ADR 0117).
2445        warnings: Vec::new(),
2446        // Populated by `finish_build` from the same `RunChecks::Checked` this
2447        // whole `ProjectOutput` was built from.
2448        snapshots: Vec::new(),
2449        // Likewise (#1078) — `Some` only when the registry was on.
2450        schema_lock: None,
2451    }
2452}
2453
2454// P5.3 review (#1133): `resolve_consume_prefix` and `handler_cross_caps` used
2455// to have their own copies here, byte-identical to `bynk-check::project_model`'s
2456// (neither builds TypeScript, so neither had the codegen coupling that keeps
2457// `instantiate_provider_expr`/`native_platforms_of_context` below in this
2458// crate) — deleted, every call site repointed at
2459// `project_model::{resolve_consume_prefix, handler_cross_caps}`.
2460
2461/// v0.19 (decision 0017): the native platforms a context's **in-process
2462/// closure** commits it to: every unit whose provider its compose would
2463/// instantiate — local providers' `given` recursion plus the capabilities its
2464/// handlers reference — mapped through [`firstparty::platform_of`]. Each
2465/// platform carries an exemplar unit for the diagnostic message. Service
2466/// `consumes` edges (RPC under `workers`) do not contribute — only the
2467/// provider-instantiation walk, which is in-process by construction.
2468#[allow(clippy::too_many_arguments)]
2469fn native_platforms_of_context(
2470    ctx: &str,
2471    table: &UnitTable,
2472    unit_tables: &HashMap<String, UnitTable>,
2473    unit_consumes: &HashMap<String, Vec<String>>,
2474    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2475    unit_flattened: &HashMap<String, HashMap<String, String>>,
2476) -> std::collections::BTreeMap<Platform, String> {
2477    let mut referenced: BTreeSet<String> = BTreeSet::new();
2478    for cap in table.providers.keys() {
2479        let _ = instantiate_provider_expr(
2480            ctx,
2481            cap,
2482            unit_tables,
2483            unit_consumes,
2484            unit_consumes_aliases,
2485            unit_flattened,
2486            false,
2487            None,
2488            None,
2489            &mut referenced,
2490        );
2491    }
2492    let consumed = unit_consumes.get(ctx).cloned().unwrap_or_default();
2493    let aliases = unit_consumes_aliases.get(ctx).cloned().unwrap_or_default();
2494    let flattened = unit_flattened.get(ctx).cloned().unwrap_or_default();
2495    for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
2496        let _ = instantiate_provider_expr(
2497            &cctx,
2498            &key,
2499            unit_tables,
2500            unit_consumes,
2501            unit_consumes_aliases,
2502            unit_flattened,
2503            false,
2504            None,
2505            None,
2506            &mut referenced,
2507        );
2508    }
2509    let mut out = std::collections::BTreeMap::new();
2510    for unit in referenced {
2511        if let Some(p) = bynk_check::firstparty::platform_of(&unit) {
2512            out.entry(p).or_insert(unit);
2513        }
2514    }
2515    out
2516}
2517
2518/// #527: the DO-side deps plan for one workers context. Capability providers
2519/// cannot cross the DO wire (`{ args, deps }` is JSON — a provider's methods
2520/// die in serialisation), so the generated Durable Object reconstructs its
2521/// agents' `given` deps *inside* the DO from the same wiring compose uses.
2522#[derive(Debug, Default, Clone)]
2523pub struct AgentDepsPlan {
2524    /// agent name → TS object-literal expression for its `given` deps
2525    /// (e.g. `{ Clock: new bynk__binding.ClockProvider() }`).
2526    pub exprs: HashMap<String, String>,
2527    /// Import lines the expressions need in `handlers.ts` (binding modules,
2528    /// other Workers' handlers). Same relative depth as `compose.ts`.
2529    pub imports: Vec<String>,
2530}
2531
2532/// Build the [`AgentDepsPlan`] for context `name`, or `None` when no local
2533/// agent has `given` capabilities.
2534fn plan_agent_given_deps(
2535    name: &str,
2536    unit_info: &BTreeMap<String, UnitInfo>,
2537    adapter_bindings: &HashMap<String, AdapterBinding>,
2538) -> Option<AgentDepsPlan> {
2539    let info = unit_info.get(name)?;
2540    info.table.agents.values().next()?;
2541    let unit_tables: HashMap<String, UnitTable> = unit_info
2542        .iter()
2543        .map(|(n, i)| (n.clone(), i.table.clone()))
2544        .collect();
2545    let unit_consumes: HashMap<String, Vec<String>> = unit_info
2546        .iter()
2547        .map(|(n, i)| (n.clone(), i.consumes.clone()))
2548        .collect();
2549    let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
2550        .iter()
2551        .map(|(n, i)| (n.clone(), i.aliases.clone()))
2552        .collect();
2553    let unit_flattened: HashMap<String, HashMap<String, String>> = unit_info
2554        .iter()
2555        .map(|(n, i)| (n.clone(), i.flattened.clone()))
2556        .collect();
2557
2558    let mut referenced: BTreeSet<String> = BTreeSet::new();
2559    let mut exprs: HashMap<String, String> = HashMap::new();
2560    let mut agents: Vec<(&String, &bynk_syntax::ast::AgentDecl)> =
2561        info.table.agents.iter().collect();
2562    agents.sort_by_key(|(n, _)| (*n).clone());
2563    for (agent, a) in agents {
2564        // #1187's slice 6 (Agent/Service given wiring): reads bynk-emit::ir's
2565        // own CapRefIr (lower_handler_given_ir — a standalone reader mirroring
2566        // lower_provider_given_ir, #1200) instead of walking
2567        // bynk_syntax::ast::CapRef directly. `caps` stays keyed by bare name
2568        // (declaration-order-first dedup across every handler), just storing
2569        // CapRefIr instead of CapRef.
2570        let mut caps: std::collections::BTreeMap<String, CapRefIr> =
2571            std::collections::BTreeMap::new();
2572        for h in &a.handlers {
2573            for g in lower_handler_given_ir(h) {
2574                // Events track, slice 0 (spine #936): see the matching skip
2575                // in `handler_cross_caps` — no `EventsProvider` exists for
2576                // compose (or a synthesised DO's own reconstructed deps) to
2577                // build.
2578                if g.name == "Events"
2579                    && info.flattened.get(g.name.as_str()).map(String::as_str) == Some("bynk")
2580                {
2581                    continue;
2582                }
2583                caps.entry(g.name.clone()).or_insert(g);
2584            }
2585        }
2586        if caps.is_empty() {
2587            continue;
2588        }
2589        let parts: Vec<String> = caps
2590            .iter()
2591            .map(|(key, g)| {
2592                let target_ctx = match &g.context {
2593                    Some(p) => resolve_consume_prefix(p, &info.consumes, &info.aliases)
2594                        .unwrap_or_else(|| name.to_string()),
2595                    None => info
2596                        .flattened
2597                        .get(key.as_str())
2598                        .cloned()
2599                        .unwrap_or_else(|| name.to_string()),
2600                };
2601                let expr = instantiate_provider_expr(
2602                    &target_ctx,
2603                    key,
2604                    &unit_tables,
2605                    &unit_consumes,
2606                    &unit_consumes_aliases,
2607                    &unit_flattened,
2608                    true,
2609                    Some("env"),
2610                    None,
2611                    &mut referenced,
2612                );
2613                format!("{key}: {expr}")
2614            })
2615            .collect();
2616        exprs.insert(agent.clone(), format!("{{ {} }}", parts.join(", ")));
2617    }
2618    if exprs.is_empty() {
2619        return None;
2620    }
2621    // Providers of *this* context live in the same module (`handlers.ts`), so
2622    // their compose-namespace prefix drops.
2623    let self_ns = format!("handlers_{}.", name.replace('.', "_"));
2624    for e in exprs.values_mut() {
2625        *e = e.replace(&self_ns, "");
2626    }
2627    referenced.remove(name);
2628    let mut imports = Vec::new();
2629    for u in &referenced {
2630        let ns = u.replace('.', "_");
2631        if let Some(b) = adapter_bindings.get(u) {
2632            let module = crate::emitter::ts_specifier(&b.output_path.with_extension("js"));
2633            imports.push(format!(
2634                "import * as {ns}__binding from \"../../{module}\";"
2635            ));
2636        } else {
2637            let dir = worker_dir_name(u);
2638            imports.push(format!(
2639                "import * as handlers_{ns} from \"../{dir}/handlers.js\";"
2640            ));
2641        }
2642    }
2643    Some(AgentDepsPlan { exprs, imports })
2644}
2645
2646/// v0.15: build the TypeScript expression instantiating the provider of
2647/// capability `cap` declared in `provider_ctx`, recursively wiring its `given`
2648/// dependencies — local sibling providers and cross-context capability
2649/// providers alike. Stateless providers, so fresh instances per use are fine.
2650///
2651/// v0.18 (spec §4.5/§5.1): a *bare* `given` name resolves through the
2652/// provider's own unit's flattened-capability map (`Fetch` → `bynk`), falling
2653/// back to the unit itself; an *external* provider's deps are built the same
2654/// way and passed to the binding class constructor by name. Every unit whose
2655/// namespace the expression references is recorded in `referenced_units` so
2656/// the caller can emit the matching imports (the transitive given-closure).
2657///
2658/// Locale capability track, slice 2 (#882, Decision C): the three extra
2659/// constructor arguments `LocaleProvider` receives when its composing
2660/// context has a uniquely-detected message bundle — the JS expressions
2661/// themselves (an identifier for `request`, and the two cross-commons-
2662/// imported bundle constants), not raw data, since they're spliced directly
2663/// into the generated `new bynk__binding.LocaleProvider(...)` call.
2664pub(crate) struct LocaleNegotiationArgs {
2665    pub(crate) request_expr: String,
2666    pub(crate) declared_locales_expr: String,
2667    pub(crate) reference_locale_expr: String,
2668}
2669
2670/// `workers_ns` selects the namespace convention: a bodied provider's class
2671/// lives in `{ns}` under the bundle root but `handlers_{ns}` in a Worker
2672/// compose; external (binding) classes are `{ns}__binding` in both. When
2673/// `env_ident` is set (workers), env-taking first-party providers receive it
2674/// as a constructor argument.
2675///
2676/// Locale capability track, slice 2 (#882): `locale_negotiation`, when
2677/// `Some`, is threaded to exactly the `(bynk, LocaleProvider)` pair, the same
2678/// way `env_ident` is threaded to `provider_takes_env`'s pairs — a small,
2679/// closed set of first-party providers that need ambient, request-scoped
2680/// construction data no ordinary `given` clause could express.
2681#[allow(clippy::too_many_arguments)]
2682pub(crate) fn instantiate_provider_expr(
2683    provider_ctx: &str,
2684    cap: &str,
2685    unit_tables: &HashMap<String, UnitTable>,
2686    unit_consumes: &HashMap<String, Vec<String>>,
2687    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2688    unit_flattened: &HashMap<String, HashMap<String, String>>,
2689    workers_ns: bool,
2690    env_ident: Option<&str>,
2691    locale_negotiation: Option<&LocaleNegotiationArgs>,
2692    referenced_units: &mut BTreeSet<String>,
2693) -> String {
2694    let ns = provider_ctx.replace('.', "_");
2695    let bodied_ns = if workers_ns {
2696        format!("handlers_{ns}")
2697    } else {
2698        ns.clone()
2699    };
2700    referenced_units.insert(provider_ctx.to_string());
2701    let Some(provider) = unit_tables
2702        .get(provider_ctx)
2703        .and_then(|t| t.providers.get(cap))
2704    else {
2705        return format!("new {bodied_ns}.{cap}()");
2706    };
2707    // Build the by-name deps object from the provider's `given`, if any.
2708    // #1187's Provider given/deps-wiring slice: reads bynk-emit::ir's own
2709    // CapRefIr (lower_provider_given_ir — a standalone reader, never a full
2710    // IrItem::Provider; see that function's own doc comment for why) instead
2711    // of walking bynk_syntax::ast::CapRef directly.
2712    let given: Vec<CapRefIr> = lower_provider_given_ir(provider);
2713    let deps_obj = if given.is_empty() {
2714        None
2715    } else {
2716        let consumed = unit_consumes.get(provider_ctx).cloned().unwrap_or_default();
2717        let aliases = unit_consumes_aliases
2718            .get(provider_ctx)
2719            .cloned()
2720            .unwrap_or_default();
2721        let flattened = unit_flattened
2722            .get(provider_ctx)
2723            .cloned()
2724            .unwrap_or_default();
2725        let deps: Vec<String> = given
2726            .iter()
2727            .map(|g| {
2728                let target_ctx = match &g.context {
2729                    Some(p) => resolve_consume_prefix(p, &consumed, &aliases)
2730                        .unwrap_or_else(|| provider_ctx.to_string()),
2731                    None => flattened
2732                        .get(&g.name)
2733                        .cloned()
2734                        .unwrap_or_else(|| provider_ctx.to_string()),
2735                };
2736                let expr = instantiate_provider_expr(
2737                    &target_ctx,
2738                    &g.name,
2739                    unit_tables,
2740                    unit_consumes,
2741                    unit_consumes_aliases,
2742                    unit_flattened,
2743                    workers_ns,
2744                    env_ident,
2745                    locale_negotiation,
2746                    referenced_units,
2747                );
2748                format!("{}: {}", g.name, expr)
2749            })
2750            .collect();
2751        Some(format!("{{ {} }}", deps.join(", ")))
2752    };
2753    let mut args: Vec<String> = deps_obj.into_iter().collect();
2754    // v0.18/v0.19: env-taking first-party providers (the bynk surface's
2755    // SecretsProvider; bynk.cloudflare's WorkersKv) receive the Worker `env`
2756    // explicitly — decisions 0021/0025. Keyed by (unit, class).
2757    if provider.external
2758        && bynk_check::firstparty::provider_takes_env(provider_ctx, &provider.provider_name.name)
2759        && let Some(env) = env_ident
2760    {
2761        args.push(env.to_string());
2762    }
2763    // Locale capability track, slice 2 (#882, Decision C): only the
2764    // `(bynk, LocaleProvider)` pair ever receives these — every other
2765    // provider's construction is unaffected since every other call site
2766    // passes `None`.
2767    if provider.external
2768        && provider_ctx == bynk_check::firstparty::BYNK_UNIT
2769        && provider.provider_name.name == "LocaleProvider"
2770        && let Some(loc) = locale_negotiation
2771    {
2772        args.push(loc.request_expr.clone());
2773        args.push(loc.declared_locales_expr.clone());
2774        args.push(loc.reference_locale_expr.clone());
2775    }
2776    let class = &provider.provider_name.name;
2777    let args = args.join(", ");
2778    // v0.17: an external (adapter) provider's class lives in the binding module,
2779    // not the adapter's interface module — instantiate it from the binding
2780    // namespace (`<adapter>__binding`, imported by the composition root).
2781    if provider.external {
2782        format!("new {ns}__binding.{class}({args})")
2783    } else {
2784        format!("new {bodied_ns}.{class}({args})")
2785    }
2786}
2787
2788#[allow(clippy::too_many_arguments)]
2789/// Events track, slice 0 (spine #936): does any handler in this unit emit —
2790/// the `UnitTable`-level analogue of `emitter::commons_uses_emit`, needed
2791/// here because compose works from the project-wide `UnitTable` map, not a
2792/// single unit's `TypedCommons`. #1187's slice 6 plumbing: reads the
2793/// checker's own already-resolved `Callee::Capability{cap:"Events",
2794/// op:"emit"}` (`Events.emit[...]` dispatches through the ordinary
2795/// capability-call path, `bynk-check/src/checker/calls.rs`) instead of
2796/// `emitter::block_uses_emit`'s bare-`Ident("Events")`-receiver name match.
2797/// `callees` is `None` only defensively (a unit whose own check never ran) —
2798/// every call site this function actually reaches has already certified
2799/// (review of #1202: traced live, confirmed unreachable on the build path
2800/// today). A silent `false` here disables four emission gates at once (no
2801/// fan-out DO, no `dispatchToEventsFanout` import, no `EVENTS_FANOUT`
2802/// binding, no `__eventsDispatch` field) with no diagnostic — `debug_assert`
2803/// makes that invariant enforced, not just documented, so a future caller
2804/// that violates it fails loudly in tests rather than shipping a publishing
2805/// context that silently drops every emitted event.
2806///
2807/// `emitter::block_uses_emit` — the per-*handler* twin deciding
2808/// `emit_service`/`emit_agent`'s own `deps.__eventsDispatch` *parameter*
2809/// threading — reads the same resolved `Callee` now too (its own doc
2810/// comment has the story: the two checks briefly disagreed on a
2811/// locally-shadowed `Events` type between this function converting and
2812/// that one following, confirmed by a fixture that failed `tsc --strict` in
2813/// between, `1204_events_emit_shadowed_by_local_type`), so the two stay in
2814/// agreement on every input, not just the ones existing fixtures cover.
2815pub(crate) fn unit_table_uses_emit(
2816    table: &UnitTable,
2817    callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>,
2818) -> bool {
2819    let Some(callees) = callees else {
2820        debug_assert!(
2821            false,
2822            "unit_table_uses_emit: no Callee map for a checked unit"
2823        );
2824        return false;
2825    };
2826    fn body_uses_emit(
2827        body: &Block,
2828        callees: &HashMap<ExprId, bynk_check::checker::Callee>,
2829    ) -> bool {
2830        let mut found = false;
2831        crate::emitter::walk_block_exprs(body, &mut |e| {
2832            if !found
2833                && matches!(
2834                    callees.get(&e.id),
2835                    Some(bynk_check::checker::Callee::Capability { cap, op })
2836                        if cap == "Events" && op == "emit"
2837                )
2838            {
2839                found = true;
2840            }
2841        });
2842        found
2843    }
2844    table
2845        .services
2846        .values()
2847        .any(|s| s.handlers.iter().any(|h| body_uses_emit(&h.body, callees)))
2848        || table
2849            .agents
2850            .values()
2851            .any(|a| a.handlers.iter().any(|h| body_uses_emit(&h.body, callees)))
2852}
2853
2854/// Events track, slice 0 (spine #936): project-wide "who subscribes to
2855/// what" — for every `service ... from Events(E) { on event ... }`
2856/// anywhere in the project, resolve `E` to its *owning* context (the one
2857/// whose `events` table actually declares it — bare-name resolution, since
2858/// `TypeRef` has no dotted form) and group subscribers under
2859/// `(owning_context, event_type_name)`. No prior art to reuse: cross-context
2860/// wiring elsewhere is driven by an explicit author-written `consumes`
2861/// clause, resolved eagerly in `phase_resolve_consumes`; this is the first
2862/// wiring driven by an *implicit* relationship (a bare event-type name
2863/// shared between a publisher's `event` declaration and a subscriber's
2864/// `from Events(E)` header).
2865fn discover_event_subscribers(
2866    unit_tables: &HashMap<String, UnitTable>,
2867    unit_consumes: &HashMap<String, Vec<String>>,
2868) -> BTreeMap<(String, String), Vec<(String, String)>> {
2869    let mut out: BTreeMap<(String, String), Vec<(String, String)>> = BTreeMap::new();
2870    for (ctx_name, table) in unit_tables {
2871        for (svc_name, svc) in &table.services {
2872            let ServiceProtocol::Events { event_type, .. } = &svc.protocol else {
2873                continue;
2874            };
2875            let TypeRef::Named(id) = event_type else {
2876                continue;
2877            };
2878            let name = &id.name;
2879            let owner = if table.events.contains_key(name) {
2880                Some(ctx_name.clone())
2881            } else {
2882                unit_consumes.get(ctx_name).and_then(|consumed| {
2883                    consumed
2884                        .iter()
2885                        .find(|c| {
2886                            unit_tables
2887                                .get(c.as_str())
2888                                .is_some_and(|t| t.events.contains_key(name))
2889                        })
2890                        .cloned()
2891                })
2892            };
2893            if let Some(owner) = owner {
2894                out.entry((owner, name.clone()))
2895                    .or_default()
2896                    .push((ctx_name.clone(), svc_name.clone()));
2897            }
2898        }
2899    }
2900    // `unit_tables`/`table.services` are `HashMap`s, so the pushes above race
2901    // across builds — a multi-subscriber event's dispatch order (and so the
2902    // emitted `__eventsDispatch` closure's `await sub1; await sub2;`
2903    // sequence) would otherwise vary build to build with no source change.
2904    for subs in out.values_mut() {
2905        subs.sort();
2906    }
2907    out
2908}
2909
2910#[allow(clippy::too_many_arguments)]
2911fn emit_composition_root(
2912    groups: &BTreeMap<String, Vec<usize>>,
2913    kinds: &BTreeMap<String, UnitKind>,
2914    unit_consumes: &HashMap<String, Vec<String>>,
2915    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2916    unit_tables: &HashMap<String, UnitTable>,
2917    unit_callees: &HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
2918    adapter_bindings: &HashMap<String, AdapterBinding>,
2919    unit_flattened: &HashMap<String, HashMap<String, String>>,
2920    // v0.19 (decision 0025, D1): when the program's closure reaches a
2921    // platform-native unit, composeApp takes an optional `env` and threads it
2922    // to env-taking first-party providers. A bundle on Cloudflare is a single
2923    // Worker with `env` at its entry; native-free programs emit the v0.18
2924    // no-parameter signature unchanged.
2925    thread_env: bool,
2926    // Events track, slice 0 (spine #936): the project-wide subscriber table,
2927    // computed once by the caller (Workers mode needs the same table for its
2928    // own per-Worker fan-out wiring, so it is shared rather than rebuilt).
2929    event_subscribers: &BTreeMap<(String, String), Vec<(String, String)>>,
2930) -> Option<String> {
2931    // Identify contexts that consume something whose surface has services.
2932    let mut needs_compose = false;
2933    for (name, targets) in unit_consumes {
2934        if !targets.is_empty()
2935            && let Some(UnitKind::Context) = kinds.get(name)
2936        {
2937            for t in targets {
2938                if let Some(other) = unit_tables.get(t)
2939                    && !other.services.is_empty()
2940                {
2941                    needs_compose = true;
2942                }
2943            }
2944        }
2945    }
2946    // v0.15: also compose when a context uses a consumed context's capability
2947    // (in a handler or in a provider's `given`) — the consumer must instantiate
2948    // the provided capability's provider locally.
2949    if !needs_compose {
2950        for (name, kind) in kinds {
2951            if *kind != UnitKind::Context {
2952                continue;
2953            }
2954            let Some(table) = unit_tables.get(name) else {
2955                continue;
2956            };
2957            let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
2958            let aliases = unit_consumes_aliases.get(name).cloned().unwrap_or_default();
2959            let flattened = unit_flattened.get(name).cloned().unwrap_or_default();
2960            if !handler_cross_caps(table, &consumed, &aliases, &flattened).is_empty()
2961                || table.providers.values().any(|p| {
2962                    p.given.iter().any(|g| {
2963                        g.is_cross_context()
2964                            // v0.18: a bare given flattened from `consumes U
2965                            // { Cap }` is cross-unit too — its provider lives
2966                            // in the consumed unit.
2967                            || (g.prefix().is_none() && flattened.contains_key(g.key()))
2968                    })
2969                })
2970                // Events track, slice 0 (spine #936): a context whose
2971                // handlers emit needs its own `__eventsDispatch` closure
2972                // built by compose (§ `discover_event_subscribers`) even
2973                // when it consumes nothing and no other context consumes
2974                // it — `Events` is filtered out of `handler_cross_caps`
2975                // (there is no `EventsProvider`), so without this check a
2976                // publish-only context would never get a compose entry and
2977                // its service would simply never be called.
2978                || unit_table_uses_emit(table, unit_callees.get(name))
2979            {
2980                needs_compose = true;
2981                break;
2982            }
2983        }
2984    }
2985    if !needs_compose {
2986        return None;
2987    }
2988
2989    let mut contexts: Vec<&String> = groups
2990        .keys()
2991        .filter(|n| kinds.get(*n) == Some(&UnitKind::Context))
2992        .collect();
2993    contexts.sort();
2994
2995    // The composeApp body is built first so the provider expressions can
2996    // record every unit namespace they reference (v0.18: an external
2997    // provider's `given` may pull in *another* adapter's binding — the
2998    // transitive given-closure — which must then be imported).
2999    let mut referenced_units: BTreeSet<String> = BTreeSet::new();
3000    let mut out = String::new();
3001
3002    let (compose_params, env_ident) = if thread_env {
3003        ("env?: unknown", Some("env"))
3004    } else {
3005        ("", None)
3006    };
3007    out.push_str(&format!(
3008        "export function composeApp({compose_params}) {{\n"
3009    ));
3010
3011    // Build each context's deps and surface in dependency-respecting order:
3012    // a context that consumes another must come after the consumed context,
3013    // so its `surface` field can reference the already-built surface.
3014    let mut ordered: Vec<String> = Vec::new();
3015    let mut visited: HashSet<String> = HashSet::new();
3016    fn visit(
3017        node: &str,
3018        unit_consumes: &HashMap<String, Vec<String>>,
3019        visited: &mut HashSet<String>,
3020        out: &mut Vec<String>,
3021    ) {
3022        if visited.contains(node) {
3023            return;
3024        }
3025        visited.insert(node.to_string());
3026        if let Some(targets) = unit_consumes.get(node) {
3027            for t in targets {
3028                visit(t, unit_consumes, visited, out);
3029            }
3030        }
3031        out.push(node.to_string());
3032    }
3033    for c in &contexts {
3034        visit(c, unit_consumes, &mut visited, &mut ordered);
3035    }
3036
3037    for ctx_name in &ordered {
3038        if kinds.get(ctx_name.as_str()) != Some(&UnitKind::Context) {
3039            continue;
3040        }
3041        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
3042            continue;
3043        };
3044        // A context's deps object exists only to feed its `makeSurface`; a
3045        // capability-only context (no services) needs neither (v0.15).
3046        if table.services.is_empty() {
3047            continue;
3048        }
3049        let ns = ctx_name.replace('.', "_");
3050
3051        let mut deps_entries: Vec<String> = table
3052            .providers
3053            .keys()
3054            .map(|cap| {
3055                format!(
3056                    "{cap}: {}",
3057                    instantiate_provider_expr(
3058                        ctx_name,
3059                        cap,
3060                        unit_tables,
3061                        unit_consumes,
3062                        unit_consumes_aliases,
3063                        unit_flattened,
3064                        false,
3065                        env_ident,
3066                        None, // Bundle mode has no inbound request (Decision A)
3067                        &mut referenced_units,
3068                    )
3069                )
3070            })
3071            .collect();
3072        // v0.15: cross-context capabilities used directly by handlers become
3073        // top-level deps fields, instantiated from the providing context.
3074        {
3075            let consumed = unit_consumes
3076                .get(ctx_name.as_str())
3077                .cloned()
3078                .unwrap_or_default();
3079            let aliases = unit_consumes_aliases
3080                .get(ctx_name.as_str())
3081                .cloned()
3082                .unwrap_or_default();
3083            let flattened = unit_flattened
3084                .get(ctx_name.as_str())
3085                .cloned()
3086                .unwrap_or_default();
3087            for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
3088                deps_entries.push(format!(
3089                    "{key}: {}",
3090                    instantiate_provider_expr(
3091                        &cctx,
3092                        &key,
3093                        unit_tables,
3094                        unit_consumes,
3095                        unit_consumes_aliases,
3096                        unit_flattened,
3097                        false,
3098                        env_ident,
3099                        None, // Bundle mode has no inbound request (Decision A)
3100                        &mut referenced_units,
3101                    )
3102                ));
3103            }
3104        }
3105        // Events track, slice 0 (spine #936): a context whose handlers emit
3106        // gets an `__eventsDispatch` closure built here — Bundle/node mode
3107        // has no isolate boundary to cross, so this dispatches in-process
3108        // directly into each subscriber's own `on event` handler (`.event`,
3109        // the object method `emit_service` gives `HandlerKind::Event`),
3110        // reusing whatever deps that subscriber context already builds in
3111        // this same loop (referenced by name; the arrow function body isn't
3112        // evaluated until well after every `const ...Deps` in `composeApp`
3113        // has run, so declaration order here doesn't matter). A publisher
3114        // with no subscribers still gets the field — its type is required —
3115        // just with an empty switch.
3116        if unit_table_uses_emit(table, unit_callees.get(ctx_name)) {
3117            let mut cases = String::new();
3118            for name in table.events.keys() {
3119                let Some(subs) = event_subscribers.get(&(ctx_name.clone(), name.clone())) else {
3120                    continue;
3121                };
3122                // ADR 0284: subscriber failure isolation — one subscriber's
3123                // throw is caught and logged, not left to abort delivery to
3124                // its siblings or propagate into the already-committed
3125                // publishing handler. Mirrors the Cloudflare fan-out DO's own
3126                // per-subscriber try/catch (`emit_events_fanout_do`) so the
3127                // two targets agree on this guarantee, not just on delivery.
3128                let calls: Vec<String> = subs
3129                    .iter()
3130                    .map(|(sub_ctx, sub_svc)| {
3131                        let sub_ns = sub_ctx.replace('.', "_");
3132                        // Events track, slice 2 (spine #936): the envelope
3133                        // is only forwarded to a subscriber that declared
3134                        // the optional second `env: EventEnvelope`
3135                        // parameter — a subscriber that kept `on event(e:
3136                        // E)` sees no change to its call at all. Slice 4
3137                        // (#985): also forwarded when the subscriber's
3138                        // protocol carries a `via schema(N)` clause, even if
3139                        // undeclared — `emit_service` inserts a synthetic
3140                        // `env` parameter in that case, and needs the value
3141                        // to line up positionally.
3142                        let wants_envelope = unit_tables
3143                            .get(sub_ctx)
3144                            .and_then(|t| t.services.get(sub_svc))
3145                            .is_some_and(|s| {
3146                                let declared = s
3147                                    .handlers
3148                                    .iter()
3149                                    .find(|h| matches!(h.kind, HandlerKind::Event))
3150                                    .is_some_and(|h| h.params.len() == 2);
3151                                declared
3152                                    || matches!(
3153                                        &s.protocol,
3154                                        ServiceProtocol::Events {
3155                                            schema_dispatch: Some(_),
3156                                            ..
3157                                        }
3158                                    )
3159                            });
3160                        let call_args = if wants_envelope {
3161                            "ev.payload as any, ev.envelope"
3162                        } else {
3163                            "ev.payload as any"
3164                        };
3165                        format!(
3166                            "try {{ await {sub_ns}.{sub_svc}.event({call_args}, {sub_ns}Deps); }} catch (e) {{ console.error(\"EventsFanout delivery failed\", {{ event: ev.type, service: {sub_svc:?}, error: String(e) }}); }}"
3167                        )
3168                    })
3169                    .collect();
3170                cases.push_str(&format!("case {name:?}: {{ {} break; }} ", calls.join(" ")));
3171            }
3172            deps_entries.push(format!(
3173                "__eventsDispatch: async (events: Array<{}>) => {{ for (const ev of events) {{ switch (ev.type) {{ {cases}}} }} }}",
3174                crate::emitter::EVENTS_WIRE_EVENT_TS_TYPE
3175            ));
3176        }
3177        deps_entries.sort();
3178
3179        let mut surface_entries: Vec<String> = Vec::new();
3180        if let Some(targets) = unit_consumes.get(ctx_name.as_str()) {
3181            let aliases = unit_consumes_aliases
3182                .get(ctx_name.as_str())
3183                .cloned()
3184                .unwrap_or_default();
3185            let mut alias_for: HashMap<String, String> = HashMap::new();
3186            for (alias, target) in &aliases {
3187                alias_for.insert(target.clone(), alias.clone());
3188            }
3189            let mut sorted_targets = targets.clone();
3190            sorted_targets.sort();
3191            for t in &sorted_targets {
3192                let Some(other) = unit_tables.get(t) else {
3193                    continue;
3194                };
3195                if other.services.is_empty() {
3196                    continue;
3197                }
3198                let surface_key = alias_for
3199                    .get(t)
3200                    .cloned()
3201                    .unwrap_or_else(|| t.rsplit('.').next().unwrap_or(t.as_str()).to_string());
3202                let t_ns = t.replace('.', "_");
3203                // v0.54 (#655): a consumed context with an `on call … by c: Caller`
3204                // handler needs the *caller's* qualified name (this context) threaded
3205                // into that handler's deps as its `CallerId` identity (ADR 0092). The
3206                // shared `{t_ns}Surface` (built for the top-level entry with the
3207                // provider's own name) would carry the wrong caller, so build a
3208                // per-consumer surface instead. A caller-free provider keeps the
3209                // shared instance — byte-unchanged.
3210                let entry = if context_binds_caller(other) {
3211                    format!(
3212                        "{surface_key}: {t_ns}.makeSurface({t_ns}Deps, {})",
3213                        ts_string_literal(ctx_name)
3214                    )
3215                } else {
3216                    format!("{surface_key}: {t_ns}Surface")
3217                };
3218                surface_entries.push(entry);
3219            }
3220        }
3221        if !surface_entries.is_empty() {
3222            deps_entries.push(format!("surface: {{ {} }}", surface_entries.join(", ")));
3223        }
3224        out.push_str(&format!(
3225            "  const {ns}Deps = {{ {} }};\n",
3226            deps_entries.join(", ")
3227        ));
3228        if !table.services.is_empty() {
3229            // The top-level entry addresses the context directly; there is no
3230            // calling context, so a `by c: Caller` handler reached this way reads
3231            // the context's own qualified name (a stable, non-empty `CallerId`
3232            // within the single-trust-domain bundle).
3233            let caller_arg = if context_binds_caller(table) {
3234                format!(", {}", ts_string_literal(ctx_name))
3235            } else {
3236                String::new()
3237            };
3238            out.push_str(&format!(
3239                "  const {ns}Surface = {ns}.makeSurface({ns}Deps{caller_arg});\n",
3240            ));
3241        }
3242    }
3243    out.push('\n');
3244
3245    // Export per-context surfaces under a top-level object.
3246    out.push_str("  return {\n");
3247    for ctx_name in &contexts {
3248        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
3249            continue;
3250        };
3251        if table.services.is_empty() {
3252            continue;
3253        }
3254        let ns = ctx_name.replace('.', "_");
3255        let key = ctx_name.rsplit('.').next().unwrap_or(ctx_name.as_str());
3256        out.push_str(&format!("    {key}: {ns}Surface,\n"));
3257    }
3258    out.push_str("  };\n");
3259    out.push_str("}\n");
3260
3261    // Assemble the header now that the body has recorded which units its
3262    // provider expressions reference.
3263    let mut header = String::new();
3264    header.push_str("// Generated by bynkc — do not edit by hand.\n");
3265    header.push_str("// composition root\n\n");
3266
3267    // Import every context as a namespace.
3268    for ctx_name in &contexts {
3269        let dir = emitter::ts_specifier(&commons_dir_for(ctx_name));
3270        let ns = ctx_name.replace('.', "_");
3271        header.push_str(&format!("import * as {ns} from \"./{dir}.js\";\n"));
3272    }
3273    // v0.17: import each consumed adapter's binding module — the external
3274    // provider classes live there, not in the adapter's interface module.
3275    // v0.18: plus every adapter the provider expressions referenced through
3276    // the transitive given-closure (an adapter's external provider may depend
3277    // on another adapter's capability, spec §4.5).
3278    let mut consumed_adapters: Vec<String> = unit_consumes
3279        .iter()
3280        .filter(|(name, _)| kinds.get(*name) == Some(&UnitKind::Context))
3281        .flat_map(|(_, targets)| targets.iter().cloned())
3282        .chain(referenced_units.iter().cloned())
3283        .filter(|t| adapter_bindings.contains_key(t))
3284        .collect();
3285    consumed_adapters.sort();
3286    consumed_adapters.dedup();
3287    for adapter in &consumed_adapters {
3288        let ns = adapter.replace('.', "_");
3289        let module =
3290            emitter::ts_specifier(&adapter_bindings[adapter].output_path.with_extension("js"));
3291        header.push_str(&format!("import * as {ns}__binding from \"./{module}\";\n"));
3292    }
3293    header.push('\n');
3294
3295    let out = format!("{header}{out}");
3296
3297    Some(out)
3298}
3299
3300// -- internals --
3301
3302/// Context passed to the emitter so it can resolve cross-file and
3303/// cross-unit references into TypeScript import statements.
3304pub(crate) struct EmitProjectCtx {
3305    /// Source path of the file being emitted (relative to project root).
3306    pub source_path: PathBuf,
3307    /// Joined name of the commons or context this file belongs to.
3308    pub commons_name: String,
3309    /// Which file declares each name in the local unit.
3310    pub file_decl_index: FileDeclIndex,
3311    /// For each imported name, the joined name of the unit it came from.
3312    pub imported_from: HashMap<String, String>,
3313    /// For each imported name, the kind (commons vs context) of the source unit.
3314    pub imported_from_kind: HashMap<String, UnitKind>,
3315    /// For each imported unit, the file path that declares each name.
3316    pub imported_decl_paths: HashMap<String, HashMap<String, PathBuf>>,
3317    /// What kind of unit this is.
3318    pub unit_kind: UnitKind,
3319    /// For contexts: this context's qualified name (used as the brand for
3320    /// rebranded mixed-in types and exported types).
3321    pub owning_context: Option<String>,
3322    /// For contexts: exports of each consumed context (so the emitter knows
3323    /// which names to import and how).
3324    pub exports_for_consumed: HashMap<String, HashMap<String, Visibility>>,
3325    /// For contexts: full cross-context information (consumed contexts,
3326    /// aliases, consumed services and types). Mirrors what the resolver
3327    /// and checker see (v0.6).
3328    pub cross_context: resolver::CrossContextInfo,
3329    /// v0.8 build target. Workers mode reroutes cross-context calls through
3330    /// Service Bindings and adds per-Worker entry/composition artefacts.
3331    pub target: BuildTarget,
3332    /// Agent names declared in this unit. The body lowering uses this set
3333    /// to recognise `Agent(key)` construction and `agent_instance.method(...)`
3334    /// dispatch.
3335    pub local_agents: HashSet<String>,
3336    /// #527: for each local agent with `given` capabilities, the TS
3337    /// expression building those deps DO-side (workers contexts only; the DO
3338    /// wire cannot carry providers). Consumed by `emit_agent`'s fetch branch.
3339    pub agent_given_deps: HashMap<String, String>,
3340    /// #527: extra import lines `handlers.ts` needs for the expressions above.
3341    pub extra_import_lines: Vec<String>,
3342    /// #527: for each local agent, each `on call` method's `given` capability
3343    /// list. The lowering records which agent methods a handler body calls so
3344    /// the handler's emitted deps *type* carries the callee's capabilities —
3345    /// the runtime deps value (built by compose) always did.
3346    pub agent_method_givens: HashMap<String, HashMap<String, Vec<CapRefIr>>>,
3347    /// v0.47: the context's actor declarations (merged across files), keyed by
3348    /// name. Used to resolve a handler's Bearer verification seam in `emit.rs`
3349    /// regardless of which file declares the actor.
3350    pub actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
3351    /// Events slice 3b (#978): each locally-declared event's resolved
3352    /// `@schema(N)` version (or `1` if absent), merged across files the same
3353    /// way `actors` is above — `Events.emit[E]`'s lowering site only has
3354    /// `E`'s bare name (the turbofish type argument), never its declaration,
3355    /// so this is threaded down to `ModuleCtx`/`LowerCtx` rather than
3356    /// re-derived from the per-file synthetic `Commons` `lower.rs` otherwise
3357    /// sees (which would silently miss an event declared in a sibling file).
3358    pub event_schema_versions: HashMap<String, i64>,
3359    /// v0.17: consumed unit names that are adapters. An adapter is not a Worker,
3360    /// so in workers mode its capability types are imported from its root module
3361    /// (`<adapter>.ts`), not from a per-Worker `handlers.ts`.
3362    pub consumed_adapters: HashSet<String>,
3363    /// Slice 2: the extension emitted import specifiers use (`.js` default; `.ts`
3364    /// for the `bynkc test --inspect` debug build). Consulted by `runtime_import_for`
3365    /// and the sibling/cross-commons specifier helpers.
3366    pub import_ext: ImportExt,
3367    /// v0.115 (testing track slice 3): emit the function-contract call-site guard
3368    /// (dev/test profile). Stripped in the deploy build for zero runtime cost.
3369    pub contracts: bool,
3370    /// v0.119 (testing track slice 7, ADR 0155): agent names a `for all run:
3371    /// History[Agent]` property in this project drives. Only these agents gain the
3372    /// exported `__bynkDriveHistory_<Agent>` test-support driver — every other
3373    /// agent's emission is byte-for-byte unchanged.
3374    pub history_target_agents: HashSet<String>,
3375    /// v0.132.1 (#481): for a context, the user-defined attached methods of each
3376    /// `uses`-imported refined/opaque type, keyed by the type's name and sorted
3377    /// by method name. The context's own `TypedCommons` merges the imported
3378    /// *types* but not their fn items, so `emit_context_rebrands` reads this to
3379    /// forward `Cents.fromInt(…)` and friends onto the rebranded const. Empty
3380    /// for commons units and for contexts with no such imports.
3381    pub imported_methods: HashMap<String, Vec<FnDecl>>,
3382    /// Which conditional `runtime.ts` helpers this file's emission referenced.
3383    ///
3384    /// Unlike every field above, this is an **output**, not an input: emission
3385    /// writes it (through `&self`, via interior mutability) and the header /
3386    /// import post-pass reads it back. It rides on the context because the
3387    /// producers — the `Bytes` kernel in `lower`, the boundary codecs in
3388    /// `serialisation`, the ICU formatters in `emit` — already receive `&ctx`,
3389    /// so no other signature has to change to carry the fact up.
3390    ///
3391    /// One `EmitProjectCtx` is built per emitted file, immediately before its
3392    /// `emit_project` call, so the flags cannot leak between files. Replaces a
3393    /// substring scan of the generated text; see `emitter::runtime_use`.
3394    pub runtime_use: crate::emitter::RuntimeUse,
3395}
3396
3397impl EmitProjectCtx {
3398    pub fn commons_path(name: &str) -> PathBuf {
3399        commons_dir_for(name)
3400    }
3401}
3402
3403#[allow(dead_code)]
3404fn _ensure_components_used(_p: &Path) {
3405    let _ = Component::CurDir;
3406}
3407
3408/// v0.177 (#643, review of #658): the cross-context services a context actually
3409/// **calls**, as `consumed context → service names`.
3410///
3411/// This is not the same as "every service the dependency provides", and the
3412/// difference is the difference between a gate that reports what it *knows* is
3413/// skewed and one that reports what merely *differs*. If `payment` provides
3414/// `authorise` and `refund`, `orders` calls only `authorise`, and `refund`'s
3415/// contract changed, then recording `refund` in `orders`'s `expects` would refuse
3416/// `deploy --context orders` over a service `orders` never touches and whose
3417/// runtime check could never fire. ADR 0200 Decision E rejects a per-*context*
3418/// hash for exactly this reason — that it becomes a deployment tax — and a
3419/// per-context *gate* over per-service hashes would reintroduce it one layer up.
3420///
3421/// So the manifest's `expects` mirrors the runtime check's granularity: one entry
3422/// per call site, discovered the same way the lowering discovers it — an ident
3423/// chain on the receiver that resolves to a consumed context.
3424fn called_cross_context_services(
3425    table: &UnitTable,
3426    consumed: &[String],
3427    // #1187's slice 6 plumbing: reads the checker's own already-resolved
3428    // `Callee::Cross { unit, service }` (`RunChecks::Checked::unit_callees`'s
3429    // own doc comment has the full grounding) instead of re-deriving
3430    // cross-context-ness by flattening a receiver's own ident chain and
3431    // string-matching it against `consumed`/`aliases` — the identical
3432    // resolution `CrossContextInfo::resolve_prefix` already did once, at
3433    // check time, per call site. `consumed` stays, purely as the cheap
3434    // early-out below: an empty `consumes` list means no `Callee::Cross`
3435    // could exist in this unit's own bodies regardless, so skip the walk.
3436    callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>,
3437) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
3438    let mut out: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
3439        std::collections::BTreeMap::new();
3440    if consumed.is_empty() {
3441        return out;
3442    }
3443    // See `unit_table_uses_emit`'s own matching `debug_assert` (review of
3444    // #1202) — `consumed` non-empty means this unit certified with a real
3445    // `consumes`, so `callees` missing here is the same "invariant broke a
3446    // thousand lines away" case, just silently thinning the contracts
3447    // manifest's `expects` instead of silently disabling emission.
3448    let Some(callees) = callees else {
3449        debug_assert!(
3450            false,
3451            "called_cross_context_services: no Callee map for a checked unit with a non-empty \
3452             consumes list"
3453        );
3454        return out;
3455    };
3456    let mut visit = |e: &bynk_syntax::ast::Expr| {
3457        if let Some(bynk_check::checker::Callee::Cross { unit, service }) = callees.get(&e.id) {
3458            out.entry(unit.clone()).or_default().insert(service.clone());
3459        }
3460    };
3461    for service in table.services.values() {
3462        for h in &service.handlers {
3463            emitter::walk_block_exprs(&h.body, &mut visit);
3464        }
3465    }
3466    for agent in table.agents.values() {
3467        for h in &agent.handlers {
3468            emitter::walk_block_exprs(&h.body, &mut visit);
3469        }
3470    }
3471    for provider in table.providers.values() {
3472        for op in &provider.ops {
3473            emitter::walk_block_exprs(&op.body, &mut visit);
3474        }
3475    }
3476    out
3477}
3478
3479/// v0.177 (#643): a context's own `on call` contract hashes, keyed by service
3480/// name — the constants its Worker entry compares an incoming
3481/// `X-Bynk-Contract` against.
3482///
3483/// Built by projecting each local `on call` handler into the **same**
3484/// `CrossContextService` shape the resolver hands a *caller* for the same
3485/// service (`symbols.rs`'s `build_cross_context_info`), and hashing it from the
3486/// same combined type table. That symmetry is the whole correctness argument: a
3487/// caller and a callee compiled from one source tree must agree, or the check
3488/// fires on every call instead of only on real skew.
3489fn own_contract_hashes(
3490    table: &UnitTable,
3491    own_types: &HashMap<String, Arc<bynk_syntax::ast::TypeDecl>>,
3492) -> std::collections::BTreeMap<String, String> {
3493    let mut out = std::collections::BTreeMap::new();
3494    for (sname, sdecl) in &table.services {
3495        let Some(handler) = sdecl
3496            .handlers
3497            .iter()
3498            .find(|h| matches!(h.kind, HandlerKind::Call))
3499        else {
3500            continue;
3501        };
3502        let svc = bynk_check::resolver::CrossContextService {
3503            name: sname.clone(),
3504            params: handler
3505                .params
3506                .iter()
3507                .map(|p| (p.name.name.clone(), p.type_ref.clone()))
3508                .collect(),
3509            return_type: handler.return_type.clone(),
3510            span: sdecl.span,
3511        };
3512        out.insert(
3513            sname.clone(),
3514            bynk_check::contract::service_contract_hash(&svc, own_types),
3515        );
3516    }
3517    out
3518}
3519
3520#[cfg(test)]
3521mod tests {
3522    use super::*;
3523    use std::fs;
3524
3525    /// Regression (code review of #1114): a `sources` key that matches none
3526    /// of `trees`'s roots (shouldn't happen for a well-formed map — see
3527    /// `sources_to_discovered`'s own doc) must fall back to the *last* tree,
3528    /// matching the pre-R3.9 two-tree `partition`'s fallback, not silently
3529    /// switch to the first.
3530    #[test]
3531    fn sources_to_discovered_unmatched_key_falls_back_to_the_last_tree() {
3532        let trees = vec![
3533            (PathBuf::from("/proj/src"), PathBuf::from("src")),
3534            (PathBuf::from("/proj/tests"), PathBuf::from("tests")),
3535        ];
3536        let mut sources = HashMap::new();
3537        sources.insert(PathBuf::from("/proj/src/a.bynk"), "commons a\n".to_string());
3538        sources.insert(
3539            PathBuf::from("/elsewhere/stray.bynk"),
3540            "commons stray\n".to_string(),
3541        );
3542        let (_, discovered) = sources_to_discovered(&sources, &trees);
3543        let buckets = discovered.expect("a sources map always yields Some(Discovered)");
3544        assert_eq!(buckets[0], vec![PathBuf::from("/proj/src/a.bynk")]);
3545        assert_eq!(
3546            buckets[1],
3547            vec![PathBuf::from("/elsewhere/stray.bynk")],
3548            "an unmatched key must land in the last tree, not the first"
3549        );
3550    }
3551
3552    /// Content-ownership track (#1086) slice 4: this crate's own
3553    /// `#[cfg(test)]` module can't depend on the cross-crate `bynk-testkit`
3554    /// (that crate depends on `bynk-emit` — a cyclic dev-dependency, the
3555    /// same class of issue slice 3 found for `bynk-ide`). Mirrors
3556    /// `bynk-testkit::compile_options_split` in-crate instead, directly
3557    /// against this crate's own `Roots`/`discover_project_files` — no second
3558    /// resolution to drift from the first. Keyed by the literal discovered
3559    /// path, not canonicalised, matching `bynk-testkit`'s own convention
3560    /// (canonicalising broke a project-consistency check the hard way in
3561    /// slice 3).
3562    /// Content-ownership track (#1086) slice 5: this crate's own tests can't
3563    /// depend on `bynk-testkit` (cyclic — `bynk-testkit` depends on
3564    /// `bynk-emit`), so its handful of sites that build a `Roots` and need
3565    /// real disk content for it mirror `bynk-testkit`'s own read, in-crate.
3566    fn read_disk_sources(roots: &Roots) -> HashMap<PathBuf, String> {
3567        discover_project_files(roots)
3568            .into_iter()
3569            .filter_map(|p| {
3570                let content = std::fs::read_to_string(&p).ok()?;
3571                Some((p, content))
3572            })
3573            .collect()
3574    }
3575
3576    fn compile_options_split_with_sources(
3577        project_root: PathBuf,
3578        paths: ProjectPaths,
3579    ) -> CompileOptions {
3580        let roots = Roots::Split {
3581            project_root: project_root.clone(),
3582            paths: paths.clone(),
3583        };
3584        let sources = read_disk_sources(&roots);
3585        CompileOptions::split(project_root, paths).sources(sources)
3586    }
3587
3588    // -- Finding #55/#65: memoized first-party parse must not leak gating ----
3589
3590    /// The first-party parse cache (`firstparty_parsed`) is keyed per-source,
3591    /// not per-project — `phase_parse`'s `consumes`/`uses` gating still runs
3592    /// fresh for every project. Two in-memory projects that gate in
3593    /// *different* first-party units, compiled back-to-back in the same
3594    /// process (so both share the same cache), must each see exactly their
3595    /// own gated-in set: `bynk.map` (which itself `uses bynk.list`) for the
3596    /// first, `bynk.string` alone for the second — never the other's.
3597    #[test]
3598    fn firstparty_cache_does_not_leak_gating_across_projects() {
3599        let out = compile_in_memory(
3600            "commons app.only_map\n\nuses bynk.map\n\nfn f() -> Int { 1 }\n",
3601            BuildTarget::Bundle,
3602            Default::default(),
3603        )
3604        .unwrap_or_else(|_| panic!("`uses bynk.map` should compile"));
3605        let paths: Vec<String> = out
3606            .files
3607            .iter()
3608            .map(|f| f.output_path.to_string_lossy().replace('\\', "/"))
3609            .collect();
3610        assert!(paths.iter().any(|p| p == "bynk/map.ts"), "{paths:?}");
3611        assert!(
3612            paths.iter().any(|p| p == "bynk/list.ts"),
3613            "bynk.map itself uses bynk.list, so list must be injected too: {paths:?}"
3614        );
3615        assert!(
3616            !paths.iter().any(|p| p.contains("string")),
3617            "a project that never uses bynk.string must not gain it: {paths:?}"
3618        );
3619
3620        let out2 = compile_in_memory(
3621            "commons app.only_string\n\nuses bynk.string\n\nfn f() -> String { \"x\" }\n",
3622            BuildTarget::Bundle,
3623            Default::default(),
3624        )
3625        .unwrap_or_else(|_| panic!("`uses bynk.string` should compile"));
3626        let paths2: Vec<String> = out2
3627            .files
3628            .iter()
3629            .map(|f| f.output_path.to_string_lossy().replace('\\', "/"))
3630            .collect();
3631        assert!(paths2.iter().any(|p| p == "bynk/string.ts"), "{paths2:?}");
3632        assert!(
3633            !paths2.iter().any(|p| p.contains("map")),
3634            "the shared first-party parse cache must not leak the first \
3635             project's gating into this one: {paths2:?}"
3636        );
3637    }
3638
3639    // -- Finding #64: `check_project` must not bail past an earlier error --
3640
3641    /// `compile_project`'s `Mode::Build` bails at the first structural error
3642    /// (here, `exports capability` naming an undeclared capability) and never
3643    /// reaches the per-unit checking pass — so a completely separate file's
3644    /// test-body type error is silently dropped. `check_project`'s
3645    /// `Mode::Analyse` must report both.
3646    #[test]
3647    fn check_project_reports_a_test_body_error_past_an_earlier_structural_error() {
3648        let root = scratch_project(
3649            "check_past_error",
3650            &[
3651                ("bynk.toml", "[project]\nname = \"c\"\n"),
3652                (
3653                    "src/greet.bynk",
3654                    "context greet {\n  exports capability { Bogus }\n}\n",
3655                ),
3656                (
3657                    "src/math.bynk",
3658                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
3659                ),
3660                (
3661                    "tests/math_test.bynk",
3662                    "suite math\n\ncase \"broken\" {\n  let x: Int = \"not an int\"\n  expect x == 1\n}\n",
3663                ),
3664            ],
3665        );
3666        let options = compile_options_split_with_sources(
3667            root.to_path_buf(),
3668            try_read_project_paths(&root).expect("well-formed fixture manifest"),
3669        );
3670
3671        let check = check_project(&options);
3672        assert!(check.has_errors());
3673        let categories: Vec<&str> = check.errors.iter().map(|ae| ae.error.category).collect();
3674        assert!(
3675            categories.contains(&"bynk.exports.undeclared_capability"),
3676            "{categories:?}"
3677        );
3678        assert!(
3679            categories.contains(&"bynk.types.let_annotation_mismatch"),
3680            "check_project must still report the test body's own type error \
3681             past the earlier structural error: {categories:?}"
3682        );
3683
3684        // The contrast: `compile_project`'s bail-fast `Mode::Build` is the
3685        // defect `check_project` exists to route `bynk check` around.
3686        let failure = match compile_project(&options) {
3687            Err(f) => f,
3688            Ok(_) => panic!("the structural error must still fail a real build"),
3689        };
3690        let failure_categories: Vec<&str> =
3691            failure.errors.iter().map(|ae| ae.error.category).collect();
3692        assert!(
3693            !failure_categories.contains(&"bynk.types.let_annotation_mismatch"),
3694            "compile_project must still bail before the test-body check runs \
3695             (documents why check_project is a separate entry point): {failure_categories:?}"
3696        );
3697    }
3698
3699    // -- Slice 0: file identity is not the unit-validation path ---------------
3700
3701    /// The defect, reproduced hermetically: two `include` roots each holding a
3702    /// file of the same name. Before slice 0 this yielded
3703    /// `["thing.bynk", "thing.bynk"]` — `parse_tree` stripped each tree's own
3704    /// root, so the two were indistinguishable and any consumer mapping by that
3705    /// key dropped one.
3706    ///
3707    /// This is the measurement from the track doc's §3.1, inverted into an
3708    /// assertion. It deliberately does **not** read `../examples/todo`:
3709    /// `bynk-emit` is published without an `exclude` list, so a test reaching
3710    /// outside the crate would fail a standalone `cargo test` on the released
3711    /// tarball. That `examples/todo` itself resolves is #647's regression
3712    /// fixture, where the LSP can actually observe it.
3713    #[test]
3714    fn split_roots_give_each_file_a_distinct_identity() {
3715        let root = scratch_project(
3716            "identity",
3717            &[
3718                ("bynk.toml", "[project]\nname = \"identity\"\n"),
3719                ("src/thing.bynk", "context thing\n"),
3720                ("tests/thing.bynk", "suite thing\n"),
3721            ],
3722        );
3723        let roots = Roots::Split {
3724            project_root: root.to_path_buf(),
3725            paths: try_read_project_paths(&root).expect("well-formed fixture manifest"),
3726        };
3727        let trees = roots.trees();
3728        assert_eq!(
3729            trees,
3730            vec![
3731                (root.join("src"), PathBuf::from("src")),
3732                (root.join("tests"), PathBuf::from("tests")),
3733            ],
3734            "the fixture must actually be two-rooted"
3735        );
3736        let sources = read_disk_sources(&roots);
3737        let run = run_checks(
3738            &trees,
3739            BuildTarget::Bundle,
3740            Platform::default(),
3741            ImportExt::Js,
3742            Mode::Analyse,
3743            &sources,
3744            &roots.excludes(),
3745            None,
3746            false,
3747            &SchemaLock::Off,
3748            roots.project_root(),
3749            &Arc::new(Types::new()),
3750        );
3751        let snapshots = match run {
3752            RunChecks::Bailed { snapshots, .. } => snapshots,
3753            RunChecks::Checked { snapshots, .. } => snapshots,
3754        };
3755        let mut keys: Vec<String> = snapshots
3756            .iter()
3757            .map(|(p, _)| p.to_string_lossy().replace('\\', "/"))
3758            .collect();
3759        keys.sort();
3760        assert_eq!(
3761            keys,
3762            vec!["src/thing.bynk", "tests/thing.bynk"],
3763            "a file's identity must be project-relative and unique across include roots"
3764        );
3765    }
3766
3767    /// Regression (code review of #1114): an adapter declared outside the
3768    /// first `include` tree used to have its `binding` module resolved
3769    /// against `trees[0]` unconditionally (`phase_group`'s old `src_root:
3770    /// &Path` parameter) — a project with `include = ["src", "adapters",
3771    /// "tests"]` and an adapter under `adapters/` would look for its binding
3772    /// under `src/`, fail to find it, and report `bynk.adapter.no_binding`
3773    /// even though the binding file exists right beside the adapter.
3774    #[test]
3775    fn adapter_binding_resolves_against_its_own_include_tree() {
3776        let root = scratch_project(
3777            "adapter_binding_tree",
3778            &[
3779                (
3780                    "bynk.toml",
3781                    "[project]\nname = \"a\"\n\n[paths]\ninclude = [\"src\", \"adapters\", \"tests\"]\n",
3782                ),
3783                (
3784                    "src/math.bynk",
3785                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
3786                ),
3787                (
3788                    "adapters/payments.bynk",
3789                    "adapter payments {\n  binding \"./payments.binding.ts\"\n\n  exports capability { Pay }\n\n  capability Pay {\n    fn charge(amount: Int) -> Effect[String]\n  }\n\n  provides Pay = RealPay\n}\n",
3790                ),
3791                (
3792                    "adapters/payments.binding.ts",
3793                    "import type { Pay } from \"./payments.js\";\n\nexport class RealPay implements Pay {\n  async charge(amount: number): Promise<string> {\n    return \"ok\";\n  }\n}\n",
3794                ),
3795            ],
3796        );
3797        let options = compile_options_split_with_sources(
3798            root.to_path_buf(),
3799            try_read_project_paths(&root).expect("well-formed fixture manifest"),
3800        );
3801        let out = compile_project(&options).unwrap_or_else(|f| {
3802            panic!(
3803                "adapter with a binding in a non-first include tree must compile: {}",
3804                render(&f.errors)
3805            )
3806        });
3807        let names: Vec<String> = out
3808            .files
3809            .iter()
3810            .map(|f| f.output_path.to_string_lossy().replace('\\', "/"))
3811            .collect();
3812        assert!(
3813            names.contains(&"payments.binding.ts".to_string()),
3814            "expected the binding to be copied into the output among {names:?}"
3815        );
3816    }
3817
3818    /// Regression (code review of the #1114 fix itself): `tree_root_for`
3819    /// compared `pf.abs_path()` (always absolute, via `std::path::absolute`)
3820    /// against `trees`' roots as-is — for the ordinary CLI shape (a relative
3821    /// project root, e.g. `bynkc build .`), every tree root stays relative,
3822    /// so `starts_with` never matched and this silently fell back to
3823    /// `trees[0]` for every file, reproducing the exact bug the fix above
3824    /// exists to close. `scratch_project`-based tests never caught this
3825    /// because their project root is always an absolute temp path.
3826    #[test]
3827    fn tree_root_for_matches_against_a_relative_tree_root() {
3828        let trees = vec![
3829            (PathBuf::from("src"), PathBuf::from("src")),
3830            (PathBuf::from("adapters"), PathBuf::from("adapters")),
3831        ];
3832        let root = Path::new("adapters");
3833        // Relative, exactly as `discover_bynk_files`/`phase_parse` would pass
3834        // it when `Roots::Split.project_root` is itself relative.
3835        let rel_path = root.join("payments.bynk");
3836        let (parsed, _warnings) = parse_sources(
3837            root,
3838            Path::new("adapters"),
3839            &rel_path,
3840            "adapter payments {\n  binding \"./payments.binding.ts\"\n\n  exports capability { Pay }\n\n  capability Pay {\n    fn charge(amount: Int) -> Effect[String]\n  }\n\n  provides Pay = RealPay\n}\n".to_string(),
3841            &mut 0,
3842            &mut 0,
3843        )
3844        .expect("trivial adapter source must parse");
3845        assert_eq!(
3846            project_model::tree_root_for(&trees, &parsed[0]),
3847            Path::new("adapters"),
3848            "must resolve to the adapter's own (relative) tree root, not trees[0] (\"src\")"
3849        );
3850    }
3851
3852    /// Regression (code review of #1114): the emitted test module's
3853    /// discovered-case location used to key off `trees.get(1)`'s prefix
3854    /// unconditionally (`tests_prefix` in `process_tests`/
3855    /// `emit_test_module`) — a project with `include = ["src", "examples",
3856    /// "tests"]` would prefix every discovered case's location with
3857    /// `examples/` (the second tree) even though the suite actually lives
3858    /// under `tests/` (the third).
3859    #[test]
3860    fn discovered_case_location_uses_the_suite_files_own_tree() {
3861        let root = scratch_project(
3862            "test_tree_prefix",
3863            &[
3864                (
3865                    "bynk.toml",
3866                    "[project]\nname = \"t\"\n\n[paths]\ninclude = [\"src\", \"examples\", \"tests\"]\n",
3867                ),
3868                (
3869                    "src/math.bynk",
3870                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
3871                ),
3872                (
3873                    "tests/math_test.bynk",
3874                    "suite math\n\ncase \"doubles\" {\n  expect double(2) == 4\n}\n",
3875                ),
3876            ],
3877        );
3878        let options = compile_options_split_with_sources(
3879            root.to_path_buf(),
3880            try_read_project_paths(&root).expect("well-formed fixture manifest"),
3881        );
3882        let out = compile_project(&options).unwrap_or_else(|f| {
3883            panic!(
3884                "a suite in the third include tree must compile: {}",
3885                render(&f.errors)
3886            )
3887        });
3888        let locations: Vec<String> = out
3889            .discovered
3890            .iter()
3891            .flat_map(|s| &s.cases)
3892            .filter_map(|c| c.location.as_ref())
3893            .map(|l| l.path.clone())
3894            .collect();
3895        assert!(
3896            locations
3897                .iter()
3898                .all(|p| p.starts_with("tests/") && !p.starts_with("examples/")),
3899            "case locations must key off the suite file's own tree (`tests/`), not the \
3900             second `include` tree (`examples/`): {locations:?}"
3901        );
3902    }
3903
3904    /// #57 (testing track): a two-file, cross-referencing project compiled
3905    /// entirely through the public `compile_project` API with no on-disk
3906    /// tree at all — `CompileOptions::sources` replaces what
3907    /// `scratch_project` below has to fake with real temp-directory I/O.
3908    /// Before this seam, exercising `uses` across two units from inside
3909    /// `bynk-emit`'s own tests meant either a `scratch_project` (real files,
3910    /// cleaned up on drop) or `bynkc`'s on-disk fixtures one crate up.
3911    #[test]
3912    fn compile_project_with_in_memory_sources_resolves_a_cross_unit_uses() {
3913        let mut sources = HashMap::new();
3914        sources.insert(
3915            PathBuf::from("shapes.bynk"),
3916            "commons shapes\n\ntype Circle = { radius: Int }\n".to_string(),
3917        );
3918        sources.insert(
3919            PathBuf::from("app.bynk"),
3920            "commons app\n\nuses shapes\n\nfn area(c: Circle) -> Int {\n  c.radius * c.radius\n}\n"
3921                .to_string(),
3922        );
3923        let options = CompileOptions::single(".").sources(sources);
3924        let out = compile_project(&options).unwrap_or_else(|f| {
3925            panic!(
3926                "in-memory sources project should compile: {:?}",
3927                ProjectFailure::flatten(f)
3928            )
3929        });
3930        let names: Vec<String> = out
3931            .files
3932            .iter()
3933            .map(|f| f.output_path.to_string_lossy().replace('\\', "/"))
3934            .collect();
3935        assert!(
3936            names.contains(&"shapes.ts".to_string()),
3937            "expected shapes.ts among {names:?}"
3938        );
3939        assert!(
3940            names.contains(&"app.ts".to_string()),
3941            "expected app.ts among {names:?}"
3942        );
3943        let app_ts = &out
3944            .files
3945            .iter()
3946            .find(|f| f.output_path == Path::new("app.ts"))
3947            .unwrap()
3948            .typescript;
3949        assert!(
3950            app_ts.contains("radius"),
3951            "app.ts should reference the cross-unit Circle field:\n{app_ts}"
3952        );
3953    }
3954
3955    /// A throwaway on-disk project, removed on drop — including when the test
3956    /// panics, which a trailing `remove_dir_all` would skip.
3957    struct Scratch(PathBuf);
3958    impl std::ops::Deref for Scratch {
3959        type Target = Path;
3960        fn deref(&self) -> &Path {
3961            &self.0
3962        }
3963    }
3964    impl Drop for Scratch {
3965        fn drop(&mut self) {
3966            let _ = fs::remove_dir_all(&self.0);
3967        }
3968    }
3969
3970    /// Build a throwaway on-disk project. The e2e fixture suite cannot express
3971    /// these cases: `expected_error.txt` asserts *category strings only*, never
3972    /// a path, so no fixture there can pin attribution — which is precisely why
3973    /// the identity collision survived to slice 0.
3974    fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
3975        let dir = std::env::temp_dir().join(format!(
3976            "bynk_slice0_{tag}_{}_{:?}",
3977            std::process::id(),
3978            std::thread::current().id()
3979        ));
3980        let _ = fs::remove_dir_all(&dir);
3981        for (rel, body) in files {
3982            let p = dir.join(rel);
3983            fs::create_dir_all(p.parent().unwrap()).unwrap();
3984            fs::write(&p, body).unwrap();
3985        }
3986        Scratch(dir)
3987    }
3988
3989    /// `AttributedError` is public API without a `Debug` impl; slice 0 is not
3990    /// the increment to add one, so tests render it themselves.
3991    fn render<'a>(errors: impl IntoIterator<Item = &'a AttributedError>) -> String {
3992        errors
3993            .into_iter()
3994            .map(|e| {
3995                format!(
3996                    "{} @ {}",
3997                    e.error.category,
3998                    e.source_path
3999                        .as_ref()
4000                        .map(|p| p.to_string_lossy().replace('\\', "/"))
4001                        .unwrap_or_else(|| "<unattributed>".into())
4002                )
4003            })
4004            .collect::<Vec<_>>()
4005            .join(", ")
4006    }
4007
4008    fn analyse_split(root: &Path) -> Vec<AttributedError> {
4009        let roots = Roots::Split {
4010            project_root: root.to_path_buf(),
4011            paths: try_read_project_paths(root).expect("well-formed fixture manifest"),
4012        };
4013        let trees = roots.trees();
4014        let sources = read_disk_sources(&roots);
4015        let run = run_checks(
4016            &trees,
4017            BuildTarget::Bundle,
4018            Platform::default(),
4019            ImportExt::Js,
4020            Mode::Analyse,
4021            &sources,
4022            &roots.excludes(),
4023            None,
4024            false,
4025            &SchemaLock::Off,
4026            roots.project_root(),
4027            &Arc::new(Types::new()),
4028        );
4029        match run {
4030            RunChecks::Bailed { errors, .. } => errors.into_all(),
4031            RunChecks::Checked { errors, .. } => errors.into_all(),
4032        }
4033    }
4034
4035    /// The defect's user-visible half: a diagnostic in a secondary-root file
4036    /// must be attributed to *that* file. Before slice 0 both roots' files were
4037    /// named `thing.bynk`, so a consumer keying by the attributed path (the LSP
4038    /// does) folded the two together and one file's diagnostics vanished.
4039    #[test]
4040    fn a_secondary_root_diagnostic_is_attributed_to_the_secondary_root_file() {
4041        let root = scratch_project(
4042            "attr",
4043            &[
4044                ("bynk.toml", "[project]\nname = \"attr\"\n"),
4045                ("src/thing.bynk", "context thing\n"),
4046                // Same basename as the src file, different root — the collision.
4047                //
4048                // A *parse* error, deliberately: `parse_tree` attributes it as
4049                // the file is read, which is the path slice 0 changed. (A
4050                // checker-level error would not do: test bodies are checked by
4051                // `process_tests` during emit, not in `Mode::Analyse` — which is
4052                // also why `bynkc check` is silent on a broken `case`.)
4053                ("tests/thing.bynk", "suite thing\n\ncase {{{ \n"),
4054            ],
4055        );
4056        let errors = analyse_split(&root);
4057        let paths: Vec<String> = errors
4058            .iter()
4059            .filter_map(|e| e.source_path.as_ref())
4060            .map(|p| p.to_string_lossy().replace('\\', "/"))
4061            .collect();
4062        assert!(
4063            !paths.is_empty(),
4064            "the fixture must produce at least one attributed diagnostic; got [{}]",
4065            render(&errors),
4066        );
4067        assert!(
4068            paths.iter().all(|p| p == "tests/thing.bynk"),
4069            "a tests-root diagnostic must be attributed to `tests/thing.bynk`, \
4070             never the bare `thing.bynk` it shares with `src/` — got {paths:?}",
4071        );
4072    }
4073
4074    /// The layout that ruled out the cheaper repair. Prefixing only the
4075    /// secondary tree would have worked for a `tests/` tree of suites — but
4076    /// ADR 0147 made test-ness *structural*, so `include[1]` may hold an
4077    /// ordinary unit. `check_path_name_alignment` reads `source_path`
4078    /// (tree-relative), so that unit must still validate: `spec/other.bynk`
4079    /// declaring `context other` is aligned, and prefixing its
4080    /// unit-validation path would have broken it.
4081    #[test]
4082    fn a_non_test_unit_in_the_secondary_root_still_validates() {
4083        let root = scratch_project(
4084            "nontest",
4085            &[
4086                (
4087                    "bynk.toml",
4088                    "[project]\nname = \"nontest\"\n\n[paths]\ninclude = [\"src\", \"spec\"]\n",
4089                ),
4090                ("src/thing.bynk", "context thing\n"),
4091                ("spec/other.bynk", "context other\n"),
4092            ],
4093        );
4094        let errors = analyse_split(&root);
4095        let alignment: Vec<&AttributedError> = errors
4096            .iter()
4097            .filter(|e| e.error.category == "bynk.project.inconsistent_commons_name")
4098            .collect();
4099        assert!(
4100            alignment.is_empty(),
4101            "a non-test unit in include[1] must still pass path/name alignment — \
4102             its unit-validation path stays tree-relative; got [{}]",
4103            render(alignment.iter().copied()),
4104        );
4105    }
4106
4107    /// End-to-end over the flat layout `conventional()` actually produces, so
4108    /// the normalisation is pinned where it is reachable and not only on the
4109    /// accessor. No e2e fixture has this layout.
4110    #[test]
4111    fn a_flat_project_with_a_manifest_reports_unprefixed_paths() {
4112        let root = scratch_project(
4113            "flat",
4114            &[
4115                ("bynk.toml", "[project]\nname = \"flat\"\n"),
4116                // Parse error: `parse_tree` attributes it as the file is read.
4117                ("thing.bynk", "context thing\n\nfn {{{ \n"),
4118            ],
4119        );
4120        let paths = try_read_project_paths(&root).expect("well-formed fixture manifest");
4121        assert_eq!(
4122            paths.include,
4123            vec![PathBuf::from(".")],
4124            "the fixture must actually exercise the flat layout"
4125        );
4126        let errors = analyse_split(&root);
4127        let attributed: Vec<String> = errors
4128            .iter()
4129            .filter_map(|e| e.source_path.as_ref())
4130            .map(|p| p.to_string_lossy().replace('\\', "/"))
4131            .collect();
4132        assert!(
4133            !attributed.is_empty(),
4134            "the fixture must produce an attributed diagnostic; got [{}]",
4135            render(&errors),
4136        );
4137        assert!(
4138            attributed.iter().all(|p| p == "thing.bynk"),
4139            "a flat project's diagnostics must report `thing.bynk`, never \
4140             `./thing.bynk` — got {attributed:?}",
4141        );
4142    }
4143
4144    /// v0.29.4: assembly yields exactly one `UnitInfo` per group, every facet
4145    /// present, with `exports`/`aliases`/`flattened` defaulting to empty for a
4146    /// unit absent from those (genuinely optional) producer maps — reproducing
4147    /// the old `.unwrap_or(empty)` read semantics as a total field.
4148    #[test]
4149    fn assemble_unit_info_yields_one_record_per_group_with_all_facets() {
4150        let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
4151        groups.insert("a.commons".to_string(), vec![0, 1]);
4152        groups.insert("a.context".to_string(), vec![2]);
4153
4154        let mut kinds: BTreeMap<String, UnitKind> = BTreeMap::new();
4155        kinds.insert("a.commons".to_string(), UnitKind::Commons);
4156        kinds.insert("a.context".to_string(), UnitKind::Context);
4157
4158        let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
4159        unit_tables.insert("a.commons".to_string(), UnitTable::default());
4160        unit_tables.insert("a.context".to_string(), UnitTable::default());
4161
4162        let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
4163        unit_uses.insert("a.context".to_string(), vec!["a.commons".to_string()]);
4164
4165        let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
4166        unit_consumes.insert("a.context".to_string(), vec![]);
4167
4168        // The genuinely-optional maps deliberately omit `a.commons` so the test
4169        // pins the empty-default behaviour.
4170        let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
4171        unit_flattened.insert("a.context".to_string(), HashMap::new());
4172        let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
4173        let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
4174        exports_visibility.insert("a.context".to_string(), HashMap::new());
4175
4176        let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
4177        unit_file_index.insert(
4178            "a.commons".to_string(),
4179            FileDeclIndex {
4180                types: HashMap::new(),
4181                fns: HashMap::new(),
4182                methods: HashMap::new(),
4183            },
4184        );
4185        // `a.context` is absent from the file index → its `file_index` defaults.
4186
4187        let info = project_model::assemble_unit_info(
4188            &groups,
4189            &kinds,
4190            &unit_tables,
4191            &unit_uses,
4192            &unit_consumes,
4193            &unit_flattened,
4194            &unit_consumes_aliases,
4195            &exports_visibility,
4196            &unit_file_index,
4197        );
4198
4199        // One record per group, no more.
4200        assert_eq!(info.len(), 2);
4201        assert!(info.contains_key("a.commons"));
4202        assert!(info.contains_key("a.context"));
4203
4204        // `files` mirrors the `groups` indices.
4205        assert_eq!(info["a.commons"].files, vec![0, 1]);
4206        assert_eq!(info["a.context"].files, vec![2]);
4207
4208        // Non-optional facets are filled from their producer maps.
4209        assert_eq!(info["a.commons"].kind, UnitKind::Commons);
4210        assert_eq!(info["a.context"].kind, UnitKind::Context);
4211        assert_eq!(info["a.context"].uses, vec!["a.commons".to_string()]);
4212
4213        // Optional facets default to empty for the unit with no entry.
4214        assert!(info["a.commons"].exports.is_empty());
4215        assert!(info["a.commons"].aliases.is_empty());
4216        assert!(info["a.commons"].flattened.is_empty());
4217        // And the absent `file_index` is an empty index, not a panic.
4218        assert!(info["a.context"].file_index.types.is_empty());
4219        assert!(info["a.context"].file_index.fns.is_empty());
4220        assert!(info["a.context"].file_index.methods.is_empty());
4221    }
4222
4223    // -- #397: analyse_in_memory_with_types exposes expr_types (ADR 0094) -----
4224
4225    #[test]
4226    fn analyse_in_memory_with_types_reports_expr_types_for_clean_source() {
4227        let src = "commons app.demo\n\nfn good() -> Int {\n  42\n}\n";
4228        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
4229        assert!(
4230            out.errors.is_empty(),
4231            "clean source should have no errors: {:?}",
4232            out.errors
4233                .iter()
4234                .map(|e| &e.error.message)
4235                .collect::<Vec<_>>()
4236        );
4237        let offset = src.find("42").expect("source mentions 42");
4238        let ty = bynk_check::expr_types::type_at_offset(&out.expr_types, offset);
4239        assert_eq!(
4240            ty.map(|t| t.display(&out.ty_intern)),
4241            Some("Int".to_string())
4242        );
4243    }
4244
4245    #[test]
4246    fn analyse_in_memory_with_types_is_partial_under_a_sibling_error() {
4247        // ADR 0094: a function that types cleanly still contributes its
4248        // `expr_types` even though a *different* function in the same file
4249        // has an error — `check_record`'s pre-ADR-0094 all-or-nothing gate
4250        // applied per-file, not per-function, and this is the change that
4251        // relaxed it. Hover (#397) depends on this: it must not go blank
4252        // over a well-typed expression just because some other function in
4253        // the buffer is mid-edit and broken.
4254        let src = "commons app.demo\n\n\
4255            fn good() -> Int {\n  42\n}\n\n\
4256            fn bad() -> Int {\n  \"oops\"\n}\n";
4257        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
4258        assert!(
4259            !out.errors.is_empty(),
4260            "the broken function must still be reported"
4261        );
4262        let offset = src.find("42").expect("source mentions 42");
4263        let ty = bynk_check::expr_types::type_at_offset(&out.expr_types, offset);
4264        assert_eq!(
4265            ty.map(|t| t.display(&out.ty_intern)),
4266            Some("Int".to_string()),
4267            "the clean function's types must survive the sibling error"
4268        );
4269    }
4270
4271    #[test]
4272    fn analyse_in_memory_still_returns_exactly_the_typed_variants_errors() {
4273        // Pins the refactor: `analyse_in_memory` must keep delegating to
4274        // `analyse_in_memory_with_types` rather than drift into a second,
4275        // independently-maintained `run_checks` call.
4276        let src = "commons app.demo\n\nfn bad() -> Int {\n  \"oops\"\n}\n";
4277        let errs = analyse_in_memory(src, BuildTarget::Bundle, Platform::default());
4278        let typed = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
4279        assert_eq!(errs.len(), typed.errors.len());
4280        assert!(!errs.is_empty());
4281    }
4282
4283    // -- T3.3b: `expr_types` is total (R4.3, R2.5, R4.9) -----------------
4284
4285    #[test]
4286    fn a_diagnosed_resolution_failure_records_ty_error_instead_of_nothing() {
4287        // An empty list literal with no expected element type to infer from
4288        // (`bynk.types.uninferable_element_type`, `checker.rs`'s `type_of`
4289        // `ExprKind::ListLit` arm) is a genuine, diagnosed `type_of` failure
4290        // reachable from a plain `fn` — no resolver/handler-body plumbing
4291        // needed to reproduce it.
4292        let src = "commons app.demo\n\nfn bad() -> Int {\n  []\n}\n";
4293        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
4294        assert!(
4295            out.errors
4296                .iter()
4297                .any(|e| e.error.category == "bynk.types.uninferable_element_type"),
4298            "expected the uninferable-element-type diagnostic: {:?}",
4299            out.errors
4300                .iter()
4301                .map(|e| &e.error.message)
4302                .collect::<Vec<_>>()
4303        );
4304        let offset = src.find("[]").expect("source mentions []");
4305        let ty = bynk_check::expr_types::type_at_offset(&out.expr_types, offset);
4306        assert_eq!(
4307            ty.map(|t| t.display(&out.ty_intern)),
4308            Some("<type error>".to_string()),
4309            "T3.3b: a diagnosed type_of failure must record Ty::Error, not leave the span \
4310             unrecorded — {:?}",
4311            ty
4312        );
4313    }
4314}