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