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